cowrite-dev / test /smoke.js
Leandro von Werra
Merge pull request #19 from huggingface/public-share-links
7ef4ebf unverified
Raw History Blame Contribute Delete
93.9 kB
// End-to-end smoke test: runs the real server, real Yjs clients, and a fake
// agent talking over the HTTP API with an app-issued agent key. No browser needed.
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import zlib from 'node:zlib'
import path from 'node:path'
import assert from 'node:assert'
import { fileURLToPath } from 'node:url'
import * as Y from 'yjs'
import { HocuspocusProvider } from '@hocuspocus/provider'
import WebSocket from 'ws'
import { getSchema } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const PORT = 3199
const BASE = `http://localhost:${PORT}`
const DATA = path.join(root, '.smoke-data')
const schema = getSchema([StarterKit])
let server
function startServer() {
server = spawn('node', ['server/index.js'], {
cwd: root,
env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, OAUTH_CLIENT_ID: '', OAUTH_CLIENT_SECRET: '', SPACE_AUTHOR_NAME: '', ADMIN_USER: 'root-admin' },
stdio: ['ignore', 'pipe', 'pipe'],
})
server.stdout.on('data', d => process.env.SMOKE_VERBOSE && process.stdout.write('[server] ' + d))
server.stderr.on('data', d => process.stderr.write('[server:err] ' + d))
return waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server start')
}
async function stopServer() {
server.kill('SIGTERM')
await new Promise(r => server.on('exit', r))
}
async function waitFor(fn, what, ms = 10000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) {
try {
if (await fn()) return
} catch {}
await new Promise(r => setTimeout(r, 150))
}
throw new Error(`timeout waiting for ${what}`)
}
function api(token, path_, { method = 'GET', body } = {}) {
return fetch(BASE + path_, {
method,
headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
}).then(r => r.json())
}
// Read a zip the server produced, so the test checks the bytes a user would get
// rather than trusting the writer. No data descriptors, so the local headers
// carry the sizes and a single forward pass is enough.
function readZip(buf) {
const files = {}
let i = 0
while (i + 4 <= buf.length && buf.readUInt32LE(i) === 0x04034b50) {
const method = buf.readUInt16LE(i + 8)
const csize = buf.readUInt32LE(i + 18)
const nameLen = buf.readUInt16LE(i + 26)
const extraLen = buf.readUInt16LE(i + 28)
const name = buf.subarray(i + 30, i + 30 + nameLen).toString('utf8')
const start = i + 30 + nameLen + extraLen
const body = buf.subarray(start, start + csize)
if (!name.endsWith('/')) files[name] = method === 8 ? zlib.inflateRawSync(body) : Buffer.from(body)
i = start + csize
}
return files
}
async function streamPoll(key, wait = 10) {
const resp = await fetch(`${BASE}/api/mentions/stream?wait=${wait}`, { headers: { authorization: `Bearer ${key}` } })
let text = ''
for await (const chunk of resp.body) text += Buffer.from(chunk).toString()
const last = text.trim().split('\n').filter(l => !l.startsWith(':')).pop()
return JSON.parse(last)
}
function connect(docId, token) {
const doc = new Y.Doc()
const provider = new HocuspocusProvider({
url: `ws://localhost:${PORT}/collab`,
name: docId,
document: doc,
token,
})
return { doc, provider }
}
// A visitor's browser sends the share cookie on the socket handshake; node's ws
// needs it spelled out.
function shareConnect(docId, shareToken, token = undefined) {
const doc = new Y.Doc()
class CookieWS extends WebSocket {
constructor(url, protocols) {
super(url, protocols, { headers: { cookie: `ie_share=${shareToken}` } })
}
}
const provider = new HocuspocusProvider({
url: `ws://localhost:${PORT}/collab`,
name: docId,
document: doc,
token, // set to sign the visitor in as somebody with no access of their own
WebSocketPolyfill: CookieWS,
})
return { doc, provider }
}
function writeLegacyProjectDoc(file, nodes, suggestions = []) {
const doc = new Y.Doc()
doc.getXmlFragment('default').insert(0, nodes)
for (const suggestion of suggestions) doc.getMap('suggestions').set(suggestion.id, suggestion)
fs.writeFileSync(file, Buffer.from(Y.encodeStateAsUpdate(doc)))
doc.destroy()
}
function legacyTextBlock(type, text, attrs = {}) {
const block = new Y.XmlElement(type)
for (const [key, value] of Object.entries(attrs)) block.setAttribute(key, value)
const content = new Y.XmlText()
content.insert(0, text)
block.insert(0, [content])
return block
}
async function main() {
// 0. inline markdown emphasis (pure parser) — underscore emphasis must work
// AND intra-word underscores (filenames, snake_case) must stay literal
{
const { tokenizeInline } = await import('../server/md.js')
const marksOf = s => tokenizeInline(s).map(x => Object.keys(x.attrs)[0] || null)
const und = tokenizeInline('_Sources: see icm2026_schedule.html here._')
assert.strictEqual(und.length, 1, 'underscore italic is one segment')
assert.strictEqual(und[0].attrs.italic != null, true, 'underscore span is italic')
assert.ok(und[0].text.includes('icm2026_schedule.html'), 'intra-word underscore preserved inside span')
assert.deepStrictEqual(marksOf('plain snake_case_name stays literal'), [null], 'intra-word underscores are not emphasis')
assert.deepStrictEqual(marksOf('mix _italic_ and __bold__ x'), [null, 'italic', null, 'bold', null], 'both underscore forms')
assert.deepStrictEqual(marksOf('keep *star* and **bold**'), [null, 'italic', null, 'bold'], 'asterisk emphasis still works')
console.log('✓ inline emphasis: underscore italic/bold parsed, intra-word underscores literal')
// marks NEST: markdown inside a bold/italic/strike/link span must still be
// parsed. A single-pass tokenizer emitted the body verbatim, so a code span,
// link or formula inside **bold** rendered as literal backticks/brackets/$.
const nest = str => tokenizeInline(str).map(x => (x.math != null ? 'math:' + x.math : Object.keys(x.attrs).sort().join('+') + ':' + x.text))
assert.deepStrictEqual(nest('**bold with `code` here**'), ['bold:bold with ', 'bold+code:code', 'bold: here'], 'code span inside bold')
assert.deepStrictEqual(nest('**bold with *italic* here**'), ['bold:bold with ', 'bold+italic:italic', 'bold: here'], 'italic inside bold')
assert.deepStrictEqual(nest('*italic with **bold** here*'), ['italic:italic with ', 'bold+italic:bold', 'italic: here'], 'bold inside italic')
assert.deepStrictEqual(nest('**see [docs](http://x.y) now**'), ['bold:see ', 'bold+link:docs', 'bold: now'], 'link inside bold')
assert.deepStrictEqual(nest('**math $x^2$ here**'), ['bold:math ', 'math:x^2', 'bold: here'], 'formula inside bold')
assert.deepStrictEqual(nest('~~struck with **bold**~~'), ['strike:struck with ', 'bold+strike:bold'], 'bold inside strikethrough')
assert.deepStrictEqual(nest('[a **bold** link](http://x.y)'), ['link:a ', 'bold+link:bold', 'link: link'], 'bold inside a link')
assert.deepStrictEqual(nest('***both***'), ['bold+italic:both'], 'triple delimiter is bold+italic')
assert.deepStrictEqual(nest('___both___'), ['bold+italic:both'], 'triple underscore is bold+italic')
// a code span's body stays literal — that is what backticks mean
assert.deepStrictEqual(nest('`code with **stars**`'), ['code:code with **stars**'], 'markdown inside a code span is literal')
// and the serializer must nest in an order that survives a round trip:
// `code` innermost (its body is literal), link outermost
const { pmToMarkdown } = await import('../server/md.js')
const ser = marks => pmToMarkdown({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 't', marks }] }] }).trim()
assert.strictEqual(ser([{ type: 'bold' }, { type: 'code' }]), '**`t`**', 'bold+code serializes with code innermost')
assert.strictEqual(ser([{ type: 'code' }, { type: 'bold' }]), '**`t`**', 'mark order in the doc does not change the markdown')
assert.strictEqual(ser([{ type: 'bold' }, { type: 'italic' }]), '***t***', 'bold+italic serializes as ***')
for (const marks of [[{ type: 'bold' }, { type: 'code' }], [{ type: 'bold' }, { type: 'italic' }], [{ type: 'italic' }, { type: 'strike' }], [{ type: 'bold' }, { type: 'italic' }, { type: 'link', attrs: { href: 'http://x.y' } }]]) {
const segs = tokenizeInline(ser(marks))
assert.strictEqual(segs.length, 1, 'round trip stays one segment: ' + ser(marks))
const got = new Set(Object.keys(segs[0].attrs))
for (const m of marks) assert.ok(got.has(m.type), `round trip keeps ${m.type} in ${ser(marks)}`)
}
console.log('✓ inline nesting: markdown inside bold/italic/strike/link parses, marks round-trip')
}
fs.rmSync(DATA, { recursive: true, force: true })
await startServer()
console.log('✓ server started')
// 1. alice creates a doc
const { id: docId } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Smoke Test Doc' } })
assert.ok(docId, 'doc created')
// 2. ACL: bob cannot see or join the doc before it is shared
const denied = await api('dev:bob', `/api/docs/${docId}`)
assert.ok(denied.error, 'unshared doc hidden from bob')
const bobList = await api('dev:bob', '/api/docs')
assert.equal(bobList.docs.length, 0, 'doc list filtered')
const sneaky = connect(docId, 'dev:bob')
await new Promise(r => setTimeout(r, 1500))
assert.equal(sneaky.doc.getXmlFragment('default').length, 0, 'ws sync denied for unshared doc')
sneaky.provider.destroy()
console.log('✓ ACL: unshared doc invisible to others (list, read, websocket)')
// 3. alice shares with bob; bob cannot re-share
const share = await api('dev:alice', `/api/docs/${docId}/share`, { method: 'POST', body: { username: 'bob' } })
assert.equal(share.ok, true, 'share: ' + JSON.stringify(share))
const reshare = await api('dev:bob', `/api/docs/${docId}/share`, { method: 'POST', body: { username: 'carol' } })
assert.ok(reshare.error, 'only creator can share')
const bobRead = await api('dev:bob', `/api/docs/${docId}`)
assert.ok(bobRead.markdown != null, 'bob can read after share')
console.log('✓ sharing: creator adds bob, bob can read, bob cannot re-share')
// 4. bob registers an agent handle -> gets an app-issued key
const reg = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
assert.equal(reg.ok, true, 'agent registered: ' + JSON.stringify(reg))
assert.ok(reg.key?.startsWith('ak_'), 'agent key issued')
const KEY = reg.key
const steal = await api('dev:alice', '/api/agents', { method: 'POST', body: { handle: 'test-agent' } })
assert.ok(steal.error, 'cannot steal a handle')
// a new handle is unshared, so bob must share it before alice's mentions can
// reach it — the rest of this suite drives it as alice, a collaborator
const shareAgent = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: true } })
assert.equal(shareAgent.shared, true, 'fixture agent shared with collaborators: ' + JSON.stringify(shareAgent))
// agent keys are agents, not humans
const agentDoc = await api(KEY, '/api/docs', { method: 'POST', body: { title: 'nope' } })
assert.ok(agentDoc.error, 'agent key cannot create docs')
console.log('✓ agent key issued; human-only actions blocked for keys')
// key rotation invalidates the old key
const rotated = await api('dev:bob', '/api/agents/test-agent/rotate', { method: 'POST' })
assert.ok(rotated.key?.startsWith('ak_'), 'rotated')
const oldKeyPoll = await fetch(`${BASE}/api/mentions`, { headers: { authorization: `Bearer ${KEY}` } })
assert.equal(oldKeyPoll.status, 401, 'old key dead after rotation')
const AGENT = rotated.key
console.log('✓ key rotation works, old key revoked')
// 4b. the agent prompt is the whole interface an agent gets: if a line goes
// missing, that capability effectively no longer exists. Pin the contract, and
// a ceiling — the prompt is read by a model with other things to do.
{
const prompt = await fetch(`${BASE}/api/agent-prompt?doc=${docId}&handle=test-agent`, { headers: { authorization: 'Bearer dev:alice' } }).then(r => r.text())
const required = [
['/api/mentions/stream', 'how to wait for work'],
['/api/mentions/<mention_id>/dismiss', 'how to drop a follow-up'],
['/threads/<thread_id>/reply', 'how to reply'],
['/threads', 'how to open its own comment'],
['/suggestions', 'how to propose an edit'],
['/page-suggestions', 'how to propose a page'],
['/upload', 'how to add an image'],
['/structure', 'how to read the page tree'],
['block_index', 'how to anchor'],
['supersedes', 'how to revise'],
['mention_id', 'how to close the request'],
['html-embed', 'how to embed html'],
['as_agent', 'how to sign its work'],
// Both of these are live in the server and delivered on every mention, so
// a prompt that never names them makes the capability unreachable: an
// agent cannot link its comments back to the thread that asked, and reads
// a position anchor as if it were the subject of the comment.
['origin_thread_id', 'how to keep what it writes linked to the source thread'],
['anchor_kind', 'how to tell a span anchor from a position anchor'],
]
for (const [needle, why] of required) {
assert.ok(prompt.includes(needle), `prompt still says ${why} (${needle})`)
}
// Presence checks alone missed two defects: every example carried "@handle"
// (agentIdentity compares against the BARE handle, so each one would have
// been rejected as belonging to someone else) and two examples both carried
// mention_id while the text said to send it once. The examples are the
// interface — assert they would actually run.
assert.equal((prompt.match(/"as_agent": "@/g) || []).length, 0, 'examples pass the bare handle, not @handle')
assert.ok(prompt.includes('"as_agent": "test-agent"'), 'and pass it as the handle the key belongs to')
assert.equal(
(prompt.match(/"mention_id": "<mention_id>"/g) || []).length,
1,
'exactly one example closes the request, matching what the text says'
)
assert.ok(prompt.length < 9000, `prompt stays tight: ${prompt.length} chars`)
console.log(`✓ agent prompt: complete and tight (${prompt.length} chars)`)
}
// 5. alice connects over websocket, edits, creates a thread mentioning the agent
const alice = connect(docId, 'dev:alice')
const frag = alice.doc.getXmlFragment('default')
await waitFor(() => frag.length >= 2, 'initial sync')
alice.doc.transact(() => {
const p = frag.get(1)
const t = new Y.XmlText()
t.insert(0, 'This intro paragraph is quite bad and needs work.')
p.insert(0, [t])
})
console.log('✓ collab sync + edit works')
// 5b. public share links: one long URL, read-only, revocable
{
const anon = (path_, opts) => api('', path_, opts) // no bearer at all
const bobLink = await api('dev:bob', `/api/docs/${docId}/public-link`, { method: 'POST' })
assert.ok(bobLink.error, 'a collaborator cannot publish a link: ' + JSON.stringify(bobLink))
const link = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'POST' })
assert.ok(link.token && link.token.length >= 32, 'token is long: ' + JSON.stringify(link))
assert.ok(link.url.endsWith(`/p/${link.token}`), 'url points at /p/<token>: ' + link.url)
const again = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'POST' })
assert.equal(again.token, link.token, 'minting is idempotent — the same link comes back')
// a visitor with no account reads it
const view = await anon(`/api/docs/${docId}?share=${link.token}`)
assert.ok(view.markdown?.includes('intro paragraph'), 'link holder reads the doc: ' + JSON.stringify(view).slice(0, 120))
assert.deepEqual(view.shared_with, [], 'the collaborator list is not published')
// ...and nothing else
const wrong = await anon(`/api/docs/${docId}?share=${'x'.repeat(link.token.length)}`)
assert.ok(wrong.error, 'a wrong token reads nothing')
const otherDoc = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Not Shared' } })
const crossDoc = await anon(`/api/docs/${otherDoc.id}?share=${link.token}`)
assert.ok(crossDoc.error, 'a token opens only its own project: ' + JSON.stringify(crossDoc))
await api('dev:alice', `/api/docs/${otherDoc.id}`, { method: 'DELETE' })
for (const [what, call] of [
['suggest', () => anon(`/api/docs/${docId}/suggestions?share=${link.token}`, { method: 'POST', body: { block_index: 0, replacement_markdown: 'nope' } })],
['add a page', () => anon(`/api/docs/${docId}/pages?share=${link.token}`, { method: 'POST', body: { title: 'nope' } })],
['share on', () => anon(`/api/docs/${docId}/share?share=${link.token}`, { method: 'POST', body: { username: 'mallory' } })],
['delete', () => anon(`/api/docs/${docId}?share=${link.token}`, { method: 'DELETE' })],
['re-link', () => anon(`/api/docs/${docId}/public-link?share=${link.token}`, { method: 'POST' })],
]) {
const res = await call()
assert.ok(res.error, `a link holder cannot ${what}: ` + JSON.stringify(res))
}
// the socket is where read-only has to be real: connect as a visitor and edit
const viewer = shareConnect(docId, link.token)
await waitFor(() => viewer.doc.getXmlFragment('default').length >= 2, 'visitor syncs the document')
const seen = viewer.doc.getXmlFragment('default').toString()
assert.ok(seen.includes('intro paragraph'), 'visitor sees the content')
viewer.doc.transact(() => {
const t = new Y.XmlText()
t.insert(0, 'VANDALISM')
viewer.doc.getXmlFragment('default').get(1).insert(0, [t])
})
await new Promise(r => setTimeout(r, 1500))
assert.ok(!frag.toString().includes('VANDALISM'), 'the edit never reaches another client')
const afterWrite = await api('dev:alice', `/api/docs/${docId}`)
assert.ok(!afterWrite.markdown.includes('VANDALISM'), 'and it is not persisted: ' + afterWrite.markdown.slice(0, 80))
viewer.provider.destroy()
// signed in as someone with no access to THIS project: the link is what
// carries them, and the socket must agree with REST about that. Before the
// fix, a session made `user` truthy, the share branch never ran, and the
// page loaded and then never synced.
const stranger = shareConnect(docId, link.token, 'dev:mallory')
await waitFor(() => stranger.doc.getXmlFragment('default').length >= 2, 'a signed-in stranger syncs through the link')
stranger.doc.transact(() => {
const t = new Y.XmlText()
t.insert(0, 'STRANGER EDIT')
stranger.doc.getXmlFragment('default').get(1).insert(0, [t])
})
await new Promise(r => setTimeout(r, 1200))
assert.ok(!frag.toString().includes('STRANGER EDIT'), 'and is still read-only')
stranger.provider.destroy()
// a link publishes the project's TEXT, not who works on it. A signed-in
// stranger has a req.user, so "is anyone signed in?" was the wrong test.
const strangerSees = await fetch(`${BASE}/api/docs/${docId}`, {
headers: { authorization: 'Bearer dev:mallory', cookie: `ie_share=${link.token}` },
}).then(r => r.json())
assert.ok(strangerSees.markdown, 'a signed-in stranger reads the project: ' + JSON.stringify(strangerSees).slice(0, 120))
assert.deepEqual(strangerSees.shared_with, [], 'but never the collaborator list')
assert.equal(strangerSees.public_link, null, 'and never the link itself')
const agentsViaShare = await fetch(`${BASE}/api/agents?doc=${docId}`, {
headers: { authorization: 'Bearer dev:mallory', cookie: `ie_share=${link.token}` },
})
assert.equal(agentsViaShare.status, 404, 'and cannot enumerate the agents on a project they were only shown')
// the token belongs in a cookie, not in the address bar: /p/<token>
// redirects, so it never reaches history or a Referer header
const hop = await fetch(`${BASE}/p/${link.token}`, { redirect: 'manual' })
assert.equal(hop.status, 302, 'the link redirects rather than rendering')
assert.equal(hop.headers.get('location'), `/d/${docId}`, 'to the plain document URL: ' + hop.headers.get('location'))
assert.match(hop.headers.get('set-cookie') || '', /ie_share=/, 'handing the grant to a cookie on the way')
assert.equal(hop.headers.get('referrer-policy'), 'no-referrer', 'and no URL from this app travels off-origin')
// uploads are global on disk: a link must not be a key to another project's
const png = Buffer.from('89504e470d0a1a0a0000000d494844520000000100000001080600000' + '01f15c4890000000a49444154789c6360000002000100' + '05fe02fea7b5e2d40000000049454e44ae426082', 'hex')
const up = await fetch(`${BASE}/api/docs/${docId}/upload`, {
method: 'POST',
headers: { authorization: 'Bearer dev:alice', 'content-type': 'image/png' },
body: png,
}).then(r => r.json())
assert.ok(up.url, 'uploaded: ' + JSON.stringify(up))
const fileName = up.url.split('/').pop()
const mine = await fetch(`${BASE}/files/${fileName}`, { headers: { cookie: `ie_share=${link.token}` } })
assert.equal(mine.status, 200, 'a visitor sees the images of the project they were given')
const otherProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Other Project' } })
const otherLink = await api('dev:alice', `/api/docs/${otherProj.id}/public-link`, { method: 'POST' })
const theirs = await fetch(`${BASE}/files/${fileName}`, { headers: { cookie: `ie_share=${otherLink.token}` } })
assert.equal(theirs.status, 404, 'and nobody else\'s')
await api('dev:alice', `/api/docs/${otherProj.id}`, { method: 'DELETE' })
// rotate invalidates the link already handed out
const rotated2 = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'POST', body: { rotate: true } })
assert.notEqual(rotated2.token, link.token, 'rotate mints a new token')
assert.ok((await anon(`/api/docs/${docId}?share=${link.token}`)).error, 'the old link is dead')
assert.ok((await anon(`/api/docs/${docId}?share=${rotated2.token}`)).markdown, 'the new link works')
// revoke closes it entirely — including a viewer already attached, who would
// otherwise keep receiving every edit made after the link was turned off
const attached = shareConnect(docId, rotated2.token)
await waitFor(() => attached.doc.getXmlFragment('default').length >= 2, 'viewer attached before revoking')
const revoke = await api('dev:alice', `/api/docs/${docId}/public-link`, { method: 'DELETE' })
assert.ok(revoke.closed >= 1, 'revoking closed the live viewer connection: ' + JSON.stringify(revoke))
await new Promise(r => setTimeout(r, 800))
alice.doc.transact(() => {
const t = new Y.XmlText()
t.insert(0, 'AFTER REVOCATION')
frag.get(1).insert(0, [t])
})
await new Promise(r => setTimeout(r, 1500))
assert.ok(!attached.doc.getXmlFragment('default').toString().includes('AFTER REVOCATION'), 'and it receives nothing after that')
attached.provider.destroy()
assert.ok((await anon(`/api/docs/${docId}?share=${rotated2.token}`)).error, 'revoked link reads nothing')
let wsRefused = false
const dead = shareConnect(docId, rotated2.token)
dead.provider.on('authenticationFailed', () => { wsRefused = true })
await new Promise(r => setTimeout(r, 1500))
assert.ok(wsRefused || dead.doc.getXmlFragment('default').length === 0, 'a revoked link cannot open the socket either')
dead.provider.destroy()
console.log('✓ public links: read-only, scoped to one project, rotatable, revocable')
}
const threads = alice.doc.getMap('threads')
const anchorStart = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 1))).toString('base64url')
const anchorEnd = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(frag, 2))).toString('base64url')
const threadId = 'th-' + Math.random().toString(36).slice(2, 8)
alice.doc.transact(() => {
const messages = new Y.Array()
messages.push([{ id: 'msg1', author: 'alice', authorType: 'user', text: '@test-agent please improve this paragraph', ts: Date.now() }])
const t = new Y.Map()
t.set('id', threadId)
t.set('anchorStart', anchorStart)
t.set('anchorEnd', anchorEnd)
t.set('excerpt', 'This intro paragraph is quite bad')
t.set('resolved', false)
t.set('createdBy', 'alice')
t.set('createdAt', Date.now())
t.set('messages', messages)
threads.set(threadId, t)
})
await waitFor(() => {
const msgs = threads.get(threadId)?.get('messages')?.toArray() || []
return msgs[0]?.mentions?.length === 1
}, 'mention chip')
console.log('✓ mention detected, chip set')
// 5b. REGRESSION: a browser that connects while the server is opening and
// closing its own direct connections to the same document must end up on the
// instance the server keeps writing to. Disconnecting a direct connection used
// to unload the document immediately, which could drop the instance a client
// had just been attached to: the tab still reported "connected" and "synced",
// but every later server write (mention chips, agent replies, suggestions)
// landed in a fresh instance and never arrived.
{
const { id: raceDoc } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Churn Race Doc' } })
await api('dev:alice', `/api/docs/${raceDoc}/share`, { method: 'POST', body: { username: 'bob' } })
// keep opening and closing direct connections to this document for the whole
// duration of the client's handshake — the collision window is between the
// server picking up the document for the new socket and registering the
// connection on it
let churning = true
const churn = (async () => {
while (churning) await api('dev:alice', `/api/docs/${raceDoc}`)
})()
const client = connect(raceDoc, 'dev:alice')
const rFrag = client.doc.getXmlFragment('default')
await waitFor(() => rFrag.length >= 1, 'race doc initial sync')
churning = false
await churn
const rThreads = client.doc.getMap('threads')
const rid = 'race-th'
const relPos = i => Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(rFrag, i))).toString('base64url')
client.doc.transact(() => {
const messages = new Y.Array()
messages.push([{ id: 'rmsg1', author: 'alice', authorType: 'user', text: '@test-agent take a look', ts: Date.now() }])
const t = new Y.Map()
t.set('id', rid)
t.set('anchorStart', relPos(0))
t.set('anchorEnd', relPos(1))
t.set('excerpt', null)
t.set('resolved', false)
t.set('createdBy', 'alice')
t.set('createdAt', Date.now())
t.set('messages', messages)
rThreads.set(rid, t)
})
// server -> client direction #1: the chip the server writes for the mention
await waitFor(() => (rThreads.get(rid)?.get('messages')?.toArray() || [])[0]?.mentions?.length === 1, 'chip reaches a client that connected during churn')
// server -> client direction #2: an agent reply posted over HTTP
await api(AGENT, `/api/docs/${raceDoc}/threads/${rid}/reply`, { method: 'POST', body: { text: 'on it', as_agent: 'test-agent' } })
await waitFor(() => (rThreads.get(rid)?.get('messages')?.toArray() || []).some(m => m.text === 'on it'), 'agent reply reaches the same client')
client.provider.destroy()
// the collision is a race, so try it a few more times — a suggestion is the
// cheapest server write to watch for and leaves the mention queue alone
for (let round = 0; round < 3; round++) {
const { id: doc2 } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: `Churn Race ${round}` } })
await api('dev:alice', `/api/docs/${doc2}/share`, { method: 'POST', body: { username: 'bob' } })
let spinning = true
const spin = (async () => {
while (spinning) await api('dev:alice', `/api/docs/${doc2}`)
})()
const c2 = connect(doc2, 'dev:alice')
await waitFor(() => c2.doc.getXmlFragment('default').length >= 1, `race doc ${round} synced`)
spinning = false
await spin
const res = await api(AGENT, `/api/docs/${doc2}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: `round ${round} rewrite`, as_agent: 'test-agent' },
})
assert.ok(res.suggestion_id, `suggestion posted (round ${round}): ` + JSON.stringify(res))
await waitFor(() => c2.doc.getMap('suggestions').size === 1, `agent suggestion reaches the client that connected during churn (round ${round})`)
c2.provider.destroy()
}
console.log('✓ server writes reach a client that connected during direct-connection churn')
}
// 5c. REGRESSION: pages are fields of the project document, so a page-scoped
// document name is not a document. Syncing one used to load a SECOND instance
// of the project — which took over the project's mention watcher, and from
// then on @mentions written by every real browser were never detected. The
// comment landed and looked posted; no chip, no queue entry, no agent.
{
const { id: pDoc } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Page-scoped Sync' } })
// test-agent is bob's, and an agent only gets work for docs its owner can see
await api('dev:alice', `/api/docs/${pDoc}/share`, { method: 'POST', body: { username: 'bob' } })
await api('dev:alice', `/api/docs/${pDoc}/pages`, { method: 'POST', body: { title: 'Second Page' } })
const rogue = connect(`${pDoc}::second-page`, 'dev:alice')
const refused = await new Promise(resolve => {
rogue.provider.on('authenticationFailed', ({ reason }) => resolve(reason || 'refused'))
setTimeout(() => resolve(null), 5000)
})
// hocuspocus sends the client a bare "permission-denied"; the reason itself
// is only in the server log, so assert the refusal, not the wording
assert.ok(refused, 'page-scoped sync refused (no authenticationFailed within 5s)')
rogue.provider.destroy()
// and the project's own mention detection is untouched by the attempt
const client = connect(pDoc, 'dev:alice')
await waitFor(() => client.doc.getXmlFragment('default').length >= 1, 'project doc synced')
const pThreads = client.doc.getMap('threads')
client.doc.transact(() => {
const messages = new Y.Array()
messages.push([{ id: 'pmsg1', author: 'alice', authorType: 'user', text: '@test-agent still listening?', ts: Date.now() }])
const t = new Y.Map()
t.set('id', 'page-th')
t.set('excerpt', null)
t.set('resolved', false)
t.set('createdBy', 'alice')
t.set('createdAt', Date.now())
t.set('messages', messages)
pThreads.set('page-th', t)
})
await waitFor(
() => (pThreads.get('page-th')?.get('messages')?.toArray() || [])[0]?.mentions?.length === 1,
'mention still detected after a page-scoped sync attempt'
)
client.provider.destroy()
// deleting the project drops its queued mentions, so later counts stay exact
await api('dev:alice', `/api/docs/${pDoc}`, { method: 'DELETE' })
console.log('✓ page-scoped sync refused, and it cannot silence the project mention watcher')
}
// 5d. an agent opens its own comments — several small ones instead of one long
// reply — anchored to a block or about the page as a whole
{
const { id: cDoc } = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Agent Comments' } })
await api('dev:alice', `/api/docs/${cDoc}/share`, { method: 'POST', body: { username: 'bob' } })
await api('dev:alice', `/api/docs/${cDoc}/pages`, { method: 'POST', body: { title: 'Review Me' } })
const client = connect(cDoc, 'dev:alice')
const cThreads = client.doc.getMap('threads:review-me')
await waitFor(() => client.doc.getXmlFragment('page:review-me').length >= 1, 'subpage synced')
const origin = await api('dev:alice', `/api/docs/${cDoc}/threads`, {
method: 'POST',
body: { text: 'Please review this page.', block_index: 0, page: 'review-me' },
})
assert.ok(origin.thread_id, 'origin thread created: ' + JSON.stringify(origin))
const anchored = await api(AGENT, `/api/docs/${cDoc}/threads`, {
method: 'POST',
body: { text: 'This heading promises more than the page delivers.', block_index: 0, page: 'review-me', origin_thread_id: origin.thread_id, as_agent: 'test-agent' },
})
assert.equal(anchored.anchored, true, 'anchored thread: ' + JSON.stringify(anchored))
const loose = await api(AGENT, `/api/docs/${cDoc}/threads`, {
method: 'POST',
body: { text: 'Read the whole page; structure is the main problem.', page: 'review-me', origin_thread_id: origin.thread_id, as_agent: 'test-agent' },
})
assert.equal(loose.anchored, false, 'unanchored thread: ' + JSON.stringify(loose))
await waitFor(() => cThreads.size === 3, 'origin and both standalone comments reach a client')
const at = cThreads.get(anchored.thread_id)
const lt = cThreads.get(loose.thread_id)
assert.ok(at.get('anchorStart'), 'anchored thread carries anchors')
assert.ok(at.get('excerpt'), 'anchored thread quotes the text it is about: ' + at.get('excerpt'))
assert.equal(lt.get('anchorStart'), undefined, 'page-level thread carries no anchors')
assert.equal(lt.get('excerpt'), null, 'page-level thread quotes nothing')
assert.equal(at.get('originThreadId'), origin.thread_id, 'anchored comment remembers its origin')
assert.equal(lt.get('originThreadId'), origin.thread_id, 'page-level comment remembers its origin')
for (const t of [at, lt]) {
const m = t.get('messages').toArray()[0]
assert.equal(m.authorType, 'agent', 'authored as the agent')
assert.equal(m.author, 'test-agent')
// an agent's own comment must not queue work for itself
assert.equal(m.mentions, undefined, 'no self-mention chip')
}
// a human replies into a thread the agent opened
const reply = await api('dev:alice', `/api/docs/${cDoc}/threads/${anchored.thread_id}/reply`, {
method: 'POST',
body: { text: 'Fair — rewriting it.', page: 'review-me' },
})
assert.equal(reply.ok, true, 'human replies to an agent-opened thread: ' + JSON.stringify(reply))
await waitFor(() => at.get('messages').toArray().length === 2, 'reply lands in the agent thread')
// an out-of-range block index clamps to the last block rather than failing
const clamped = await api(AGENT, `/api/docs/${cDoc}/threads`, {
method: 'POST',
body: { text: 'clamped', block_index: 999, page: 'review-me', as_agent: 'test-agent' },
})
assert.equal(clamped.anchored, true, 'out-of-range block index clamps: ' + JSON.stringify(clamped))
// text is required
const empty = await api(AGENT, `/api/docs/${cDoc}/threads`, { method: 'POST', body: { block_index: 0, page: 'review-me', as_agent: 'test-agent' } })
assert.match(empty.error || '', /text required/, 'empty comment refused: ' + JSON.stringify(empty))
const badOrigin = await api(AGENT, `/api/docs/${cDoc}/threads`, {
method: 'POST',
body: { text: 'bad origin', page: 'review-me', origin_thread_id: 'missing', as_agent: 'test-agent' },
})
assert.match(badOrigin.error || '', /origin_thread_id/, 'unknown origin refused: ' + JSON.stringify(badOrigin))
client.provider.destroy()
await api('dev:alice', `/api/docs/${cDoc}`, { method: 'DELETE' })
console.log('✓ agents open their own comments: anchored to a block, or about the page')
}
// 6. snapshot shows the mention without claiming; the stream claims it
const snap0 = await api(AGENT, '/api/mentions')
assert.equal(snap0.snapshot, true, 'snapshot flagged')
assert.equal(snap0.mentions?.length, 1, 'snapshot shows pending: ' + JSON.stringify(snap0))
assert.equal(snap0.mentions[0].status, 'pending', 'snapshot does not claim')
const poll = await streamPoll(AGENT, 10)
assert.equal(poll.mentions?.length, 1, 'mention delivered: ' + JSON.stringify(poll))
const mention = poll.mentions[0]
assert.equal(mention.handle, 'test-agent')
await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'claimed', 'claimed chip')
const agents = await api('dev:alice', `/api/agents?doc=${docId}`)
assert.equal(agents.agents.find(a => a.handle === 'test-agent')?.online, true, 'agent online')
console.log('✓ mention long-poll with agent key + claim chip + presence')
// agents list scoping: outsiders' agents don't show in docs they can't access,
// the home list shows only your own handles, and mentions of outsiders go nowhere
await api('dev:carol', '/api/agents', { method: 'POST', body: { handle: 'outsider-agent' } })
const docAgents = await api('dev:alice', `/api/agents?doc=${docId}`)
assert.ok(docAgents.agents.some(a => a.handle === 'test-agent'), 'shared user agent listed in doc')
assert.ok(!docAgents.agents.some(a => a.handle === 'outsider-agent'), 'outsider agent NOT listed in doc')
const own = await api('dev:carol', '/api/agents')
assert.deepEqual(own.agents.map(a => a.handle), ['outsider-agent'], 'home list = own agents only')
alice.doc.transact(() => {
threads.get(threadId).get('messages').push([
{ id: 'msg-outsider', author: 'alice', authorType: 'user', text: '@outsider-agent do something', ts: Date.now() },
])
})
await new Promise(r => setTimeout(r, 1200))
const outsiderMsg = threads.get(threadId).get('messages').toArray().find(m => m.id === 'msg-outsider')
assert.ok(!outsiderMsg.mentions?.length, 'mentioning an outsider agent creates no task')
await api('dev:carol', '/api/agents/outsider-agent', { method: 'DELETE' })
console.log('✓ agent visibility scoped to doc access; outsider mentions inert')
// 7. the agent reads the doc with its key
const snapshot = await api(AGENT, `/api/docs/${mention.doc_id}`)
assert.ok(snapshot.markdown.includes('Smoke Test Doc'), 'doc markdown')
assert.ok(snapshot.blocks[1].markdown.includes('quite bad'), 'target block found')
// 8. suggestion + reply — identity comes from the key, no as_agent needed
const sugg = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: {
anchor_start: snapshot.blocks[1].anchor_start,
anchor_end: snapshot.blocks[1].anchor_end,
replacement_markdown: 'This **improved** intro cites [Hugging Face](https://huggingface.co) properly.\n\n- one\n- two',
rationale: 'Clearer wording, added a source.',
mention_id: mention.mention_id,
origin_thread_id: mention.origin_thread_id,
},
})
assert.equal(sugg.ok, true, 'suggestion created: ' + JSON.stringify(sugg))
await waitFor(() => alice.doc.getMap('suggestions').get(sugg.suggestion_id), 'standalone suggestion reaches client')
assert.equal(alice.doc.getMap('suggestions').get(sugg.suggestion_id).originThreadId, mention.thread_id, 'suggestion remembers origin')
assert.equal(alice.doc.getMap('suggestions').get(sugg.suggestion_id).threadId, null, 'suggestion is not grouped inside origin')
const reply = await api(AGENT, `/api/docs/${docId}/threads/${mention.thread_id}/reply`, {
method: 'POST',
body: { text: 'Proposed a rewrite with a source — see suggestions.', mention_id: mention.mention_id },
})
assert.equal(reply.ok, true, 'reply posted')
const forged = await api('dev:alice', `/api/docs/${docId}/threads/${mention.thread_id}/reply`, {
method: 'POST',
body: { text: 'fake', as_agent: 'test-agent' },
})
assert.ok(forged.error, 'as_agent forgery blocked')
console.log('✓ suggestion + reply via agent key, forgery blocked')
await waitFor(() => threads.get(threadId).get('messages').toArray()[0].mentions[0].status === 'done', 'done chip')
const msgs = threads.get(threadId).get('messages').toArray()
assert.ok(msgs.some(m => m.authorType === 'agent' && m.author === 'test-agent'), 'agent message in thread')
console.log('✓ mention marked done, agent reply attributed to the handle')
// 9. implicit follow-up + dismiss (still via key)
alice.doc.transact(() => {
threads.get(threadId).get('messages').push([
{ id: 'msg-followup', author: 'alice', authorType: 'user', text: 'hmm, can you double check the link?', ts: Date.now() },
])
})
const followPoll = await streamPoll(AGENT, 10)
assert.equal(followPoll.mentions?.length, 1, 'follow-up delivered')
assert.equal(followPoll.mentions[0].implicit_follow_up, true, 'marked implicit')
const dismissed = await api(AGENT, `/api/mentions/${followPoll.mentions[0].mention_id}/dismiss`, { method: 'POST' })
assert.equal(dismissed.ok, true, 'dismiss works')
console.log('✓ implicit follow-up delivered + dismissable via key')
// 9c. streaming long-poll: connect first, mention arrives mid-wait
const streamPromise = (async () => {
const resp = await fetch(`${BASE}/api/mentions/stream?wait=30`, { headers: { authorization: `Bearer ${AGENT}` } })
let text = ''
for await (const chunk of resp.body) text += Buffer.from(chunk).toString()
return text
})()
await new Promise(r => setTimeout(r, 1500))
alice.doc.transact(() => {
threads.get(threadId).get('messages').push([
{ id: 'msg-stream', author: 'alice', authorType: 'user', text: 'one more thing: fix the typo too', ts: Date.now() },
])
})
const streamText = await streamPromise
assert.ok(streamText.includes(':connected'), 'stream prologue present')
const lastLine = streamText.trim().split('\n').filter(l => !l.startsWith(':')).pop()
const streamResult = JSON.parse(lastLine)
assert.equal(streamResult.mentions?.length, 1, 'stream delivered mid-wait: ' + lastLine)
await api(AGENT, `/api/mentions/${streamResult.mentions[0].mention_id}/dismiss`, { method: 'POST' })
console.log('✓ streaming long-poll delivers mentions mid-wait')
// 10. agents cannot accept; alice accepts server-side
const suggMap = alice.doc.getMap('suggestions')
await waitFor(() => suggMap.size === 1, 'suggestion synced')
const suggestion = [...suggMap.values()][0]
const agentAccept = await api(AGENT, `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
assert.ok(agentAccept.error, 'agent key cannot accept suggestions')
const accepted = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
assert.equal(accepted.ok, true, 'accept: ' + JSON.stringify(accepted))
await waitFor(() => [...suggMap.values()][0].status === 'accepted', 'suggestion accepted state')
// 11. content applied with exact marks, schema-valid
await waitFor(() => {
try {
return yXmlFragmentToProseMirrorRootNode(frag, schema).textContent.includes('improved')
} catch {
return false
}
}, 'replacement applied')
const pmRoot = yXmlFragmentToProseMirrorRootNode(frag, schema)
pmRoot.check()
const boldTexts = []
const linkTexts = []
pmRoot.descendants(node => {
for (const mark of node.marks || []) {
if (mark.type.name === 'link' && mark.attrs.href === 'https://huggingface.co') linkTexts.push(node.text)
if (mark.type.name === 'bold') boldTexts.push(node.text)
}
})
assert.deepEqual(boldTexts, ['improved'], 'bold covers exactly the bold span')
assert.deepEqual(linkTexts, ['Hugging Face'], 'link covers exactly the link span')
console.log('✓ accept applied replacement; schema + exact marks ok')
const again = await api('dev:alice', `/api/docs/${docId}/suggestions/${suggestion.id}/accept`, { method: 'POST' })
assert.ok(again.error, 'double accept rejected')
// 11a. mark_only from a client that is NOT connected must still apply the
// content. A tab whose websocket is dead (rejected as outdated, offline) can
// still reach this endpoint over HTTP: trusting its "I applied it locally"
// recorded accepted suggestions whose text never reached the document — six
// of them were lost that way in a real doc before this check existed.
const ghostSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: 'Applied by the server on behalf of a dead tab.' },
})
assert.ok(ghostSugg.suggestion_id, 'ghost suggestion created: ' + JSON.stringify(ghostSugg))
// bob has access but no editor open, so his mark_only claim cannot be true
const ghostAccept = await api('dev:bob', `/api/docs/${docId}/suggestions/${ghostSugg.suggestion_id}/accept`, {
method: 'POST',
body: { mark_only: true },
})
assert.equal(ghostAccept.ok, true, 'ghost accept: ' + JSON.stringify(ghostAccept))
await waitFor(() => {
try {
return yXmlFragmentToProseMirrorRootNode(frag, schema).textContent.includes('Applied by the server on behalf of a dead tab')
} catch {
return false
}
}, 'server applied the content the disconnected client could not send')
await waitFor(() => suggMap.get(ghostSugg.suggestion_id)?.status === 'accepted', 'ghost suggestion marked accepted')
// ...while a genuinely connected client's mark_only is still honoured (no
// double application): alice IS connected here, and applies it herself.
const liveSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: 'Applied once by the live client.' },
})
assert.ok(liveSugg.suggestion_id, 'live suggestion created')
const liveRange = [...suggMap.values()].find(x => x.id === liveSugg.suggestion_id)
assert.ok(liveRange, 'live suggestion synced')
// emulate the client-side apply through the same ydoc the editor uses
alice.doc.transact(() => {
const replacement = new Y.XmlElement('paragraph')
replacement.insert(0, [new Y.XmlText('Applied once by the live client.')])
frag.delete(0, 1)
frag.insert(0, [replacement])
})
const liveAccept = await api('dev:alice', `/api/docs/${docId}/suggestions/${liveSugg.suggestion_id}/accept`, {
method: 'POST',
body: { mark_only: true },
})
assert.equal(liveAccept.ok, true, 'live accept: ' + JSON.stringify(liveAccept))
await waitFor(() => suggMap.get(liveSugg.suggestion_id)?.status === 'accepted', 'live suggestion accepted')
const liveText = yXmlFragmentToProseMirrorRootNode(frag, schema).textContent
const occurrences = liveText.split('Applied once by the live client.').length - 1
assert.equal(occurrences, 1, `client-applied replacement must not be applied twice (found ${occurrences})`)
console.log('✓ accepts are durable: server applies for a disconnected client, never double-applies for a live one')
// 11b. revisions co-exist: supersedes only links, never hides
const sugA = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: '# Better Title' },
})
const sugB = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { supersedes: sugA.suggestion_id, replacement_markdown: '# Even Better Title' },
})
assert.ok(sugA.ok && sugB.ok, 'both suggestions created')
await waitFor(() => suggMap.get(sugB.suggestion_id), 'revision synced')
assert.equal(suggMap.get(sugA.suggestion_id).status, 'open', 'original stays open alongside its revision')
assert.equal(suggMap.get(sugB.suggestion_id).revises, sugA.suggestion_id, 'revision links to the original')
for (const sid of [sugA.suggestion_id, sugB.suggestion_id]) {
const rej = await api('dev:alice', `/api/docs/${docId}/suggestions/${sid}/reject`, { method: 'POST' })
assert.equal(rej.ok, true, 'both independently actionable')
}
console.log('✓ revisions co-exist (supersedes links, human decides)')
// A thread represents one requested edit. A fuller draft for the same target
// updates its open proposal instead of creating another acceptable copy.
const threadedA = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: '# Thread draft', thread_id: 'revision-thread' },
})
const threadedB = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: '# Thread final', thread_id: 'revision-thread' },
})
assert.equal(threadedB.suggestion_id, threadedA.suggestion_id, 'same thread and target update one suggestion')
assert.equal(threadedB.updated, true, 'response identifies the in-place update')
await waitFor(() => suggMap.get(threadedA.suggestion_id)?.replacementMarkdown === '# Thread final', 'updated draft synced')
const threadedReject = await api('dev:alice', `/api/docs/${docId}/suggestions/${threadedA.suggestion_id}/reject`, { method: 'POST' })
assert.equal(threadedReject.ok, true, 'updated suggestion remains actionable')
console.log('✓ repeated proposals in one thread update in place')
// 11c. content typed AFTER a suggestion's last block must not be swallowed
const snapTail = await api(AGENT, `/api/docs/${docId}`)
const tailSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: snapTail.blocks.length - 1, replacement_markdown: 'Tail block rewritten.' },
})
assert.ok(tailSugg.ok, 'tail suggestion: ' + JSON.stringify(tailSugg))
alice.doc.transact(() => {
const p = new Y.XmlElement('paragraph')
frag.push([p])
const t = new Y.XmlText()
t.insert(0, 'appended alpha survives')
p.insert(0, [t])
})
await new Promise(r => setTimeout(r, 400))
const tailAcc = await api('dev:alice', `/api/docs/${docId}/suggestions/${tailSugg.suggestion_id}/accept`, { method: 'POST' })
assert.equal(tailAcc.ok, true, 'tail accept: ' + JSON.stringify(tailAcc))
await waitFor(async () => (await api('dev:alice', `/api/docs/${docId}`)).markdown.includes('Tail block rewritten'), 'tail applied')
const mdAfter = (await api('dev:alice', `/api/docs/${docId}`)).markdown
assert.ok(mdAfter.includes('appended alpha survives'), 'newly appended block NOT swallowed by the accept: ' + mdAfter.slice(-200))
console.log('✓ inclusive anchors: content appended after a suggestion survives its accept')
// An anchor can remain live after another edit expands the old target into
// several blocks. Refuse that stale proposal rather than duplicating the
// newly inserted neighbours.
const staleSugg = await api(AGENT, `/api/docs/${docId}/suggestions`, {
method: 'POST',
body: { block_index: 0, replacement_markdown: '# Stale rewrite', thread_id: 'stale-thread' },
})
alice.doc.transact(() => {
const inserted = new Y.XmlElement('paragraph')
inserted.insert(0, [new Y.XmlText('Inserted between target and its old neighbour.')])
frag.insert(1, [inserted])
})
const staleAccept = await api('dev:alice', `/api/docs/${docId}/suggestions/${staleSugg.suggestion_id}/accept`, { method: 'POST' })
assert.ok(staleAccept.error?.includes('changed'), 'stale structural target is rejected: ' + JSON.stringify(staleAccept))
assert.equal(suggMap.get(staleSugg.suggestion_id)?.status, 'open', 'stale suggestion stays open for revision')
const staleReject = await api('dev:alice', `/api/docs/${docId}/suggestions/${staleSugg.suggestion_id}/reject`, { method: 'POST' })
assert.equal(staleReject.ok, true, 'stale suggestion can still be rejected')
console.log('✓ structurally stale suggestions cannot be accepted')
// 11c. a comment anchored to a POSITION rather than a span — what the hover
// affordance in the right margin writes. Built here the way the client builds
// it: ONE Yjs relative position, taken INSIDE the paragraph (not at the
// fragment index of the block, which is what suggestion anchors use), written
// into both anchor fields. Nothing is quoted, so `excerpt` stays null and
// anchor_kind is what tells a reader which of the two shapes this is.
const posText = new Y.XmlText()
posText.insert(0, 'Polarised light guides bees home, which is the part nobody expects.')
alice.doc.transact(() => {
const posPara = new Y.XmlElement('paragraph')
posPara.insert(0, [posText])
frag.push([posPara])
})
const posAnchor = Buffer.from(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(posText, 0))).toString('base64url')
const posThreadId = 'th-pos-' + Math.random().toString(36).slice(2, 8)
alice.doc.transact(() => {
const messages = new Y.Array()
messages.push([{ id: 'pos-msg1', author: 'alice', authorType: 'user', text: '@test-agent worth a footnote here?', ts: Date.now() }])
const t = new Y.Map()
t.set('id', posThreadId)
t.set('anchorStart', posAnchor)
t.set('anchorEnd', posAnchor)
t.set('excerpt', null)
t.set('anchorKind', 'position')
t.set('anchorContext', 'Polarised light guides bees home, which is the part nobody expects.')
t.set('resolved', false)
t.set('createdBy', 'alice')
t.set('createdAt', Date.now())
t.set('messages', messages)
threads.set(posThreadId, t)
})
const posSnap = await api(AGENT, `/api/docs/${docId}`)
const posThread = (posSnap.threads || []).find(t => t.thread_id === posThreadId)
assert.ok(posThread, 'the position thread reaches the snapshot: ' + JSON.stringify(posSnap.threads))
assert.equal(posThread.anchor_kind, 'position', 'reported as position-anchored: ' + JSON.stringify(posThread))
assert.equal(posThread.excerpt, null, 'and quotes nothing: ' + JSON.stringify(posThread))
assert.ok(posThread.anchor_context.startsWith('Polarised light'), 'the place travels instead: ' + JSON.stringify(posThread))
// Threads written before any of this existed carry no anchorKind at all, and
// must keep reading as the span-anchored comments they are.
const spanThread = (posSnap.threads || []).find(t => t.thread_id === threadId)
assert.equal(spanThread.anchor_kind, 'span', 'a thread with no anchorKind is a span comment: ' + JSON.stringify(spanThread))
assert.ok(spanThread.excerpt, 'and still carries its quote: ' + JSON.stringify(spanThread))
// the mention has to say which kind it is, or a null excerpt is
// indistinguishable from a comment whose text was deleted
const posPoll = await streamPoll(AGENT, 10)
const posMention = (posPoll.mentions || []).find(m => m.thread_id === posThreadId)
assert.ok(posMention, 'a position comment still delivers its mention: ' + JSON.stringify(posPoll))
assert.equal(posMention.anchor_kind, 'position', 'mention says which anchor it is: ' + JSON.stringify(posMention))
assert.ok(posMention.anchored_text.startsWith('Polarised light'), 'anchored_text falls back to the place: ' + JSON.stringify(posMention))
const posReply = await api(AGENT, `/api/docs/${docId}/threads/${posThreadId}/reply`, {
method: 'POST',
body: { text: 'Added a footnote there.', mention_id: posMention.mention_id },
})
assert.equal(posReply.ok, true, 'and replies like any other thread: ' + JSON.stringify(posReply))
console.log('✓ position-anchored comments: no excerpt, anchor_kind + anchor_context reach agents')
// 11d. multi-page projects: pages, structure yaml, page-scoped suggestions
const proj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Wiki Smoke' } })
await api('dev:alice', `/api/docs/${proj.id}/share`, { method: 'POST', body: { username: 'bob' } }) // the smoke agent's owner
const pg1 = await api('dev:alice', `/api/docs/${proj.id}/pages`, { method: 'POST', body: { title: 'Design Notes' } })
assert.equal(pg1.slug, 'design-notes', 'slugified page: ' + JSON.stringify(pg1))
const st1 = await api('dev:alice', `/api/docs/${proj.id}/structure`)
assert.deepEqual(st1.tree.map(n => n.slug), ['home', 'design-notes'], 'new page lands in the tree')
// structure changes are ordinary suggestions on the _structure page
const stDoc = await api('dev:alice', `/api/docs/${proj.id}?page=_structure`)
const ci = stDoc.blocks.findIndex(b => b.markdown.includes('```'))
const ssug = await api(AGENT, `/api/docs/${proj.id}/suggestions`, {
method: 'POST',
body: { page: '_structure', block_index: ci, replacement_markdown: '```yaml\n- home\n - design-notes\n```' },
})
assert.ok(ssug.ok, 'structure suggestion: ' + JSON.stringify(ssug))
const sacc = await api('dev:alice', `/api/docs/${proj.id}/suggestions/${ssug.suggestion_id}/accept`, { method: 'POST', body: { page: '_structure' } })
assert.equal(sacc.ok, true, 'structure accept: ' + JSON.stringify(sacc))
const st2 = await api('dev:alice', `/api/docs/${proj.id}/structure`)
assert.equal(st2.tree[0]?.children?.[0]?.slug, 'design-notes', 'accepted structure suggestion re-nests the tree: ' + JSON.stringify(st2.tree))
// page-scoped agent read + suggestion + accept
const pgRead = await api(AGENT, `/api/docs/${proj.id}?page=design-notes`)
assert.ok(pgRead.markdown.includes('Design Notes'), 'agent reads the page')
assert.ok(pgRead.pages.some(p => p.slug === 'design-notes'), 'pages listed')
const psug = await api(AGENT, `/api/docs/${proj.id}/suggestions`, {
method: 'POST',
body: { page: 'design-notes', block_index: 0, replacement_markdown: '# Design Notes v2' },
})
await api('dev:alice', `/api/docs/${proj.id}/suggestions/${psug.suggestion_id}/accept`, { method: 'POST', body: { page: 'design-notes' } })
const pgRead2 = await api('dev:alice', `/api/docs/${proj.id}?page=design-notes`)
assert.ok(pgRead2.markdown.includes('Design Notes v2'), 'page-scoped accept applied')
// Both pages arrive over one websocket as named fields of one Y.Doc.
const unified = connect(proj.id, 'dev:alice')
await waitFor(() => unified.doc.getXmlFragment('default').length && unified.doc.getXmlFragment('page:design-notes').length, 'unified project sync')
assert.ok(unified.doc.getXmlFragment('default') !== unified.doc.getXmlFragment('page:design-notes'), 'pages remain isolated named fragments')
assert.ok(unified.doc.getMap('suggestions:design-notes').has(psug.suggestion_id), 'page-scoped metadata shares the project document')
unified.provider.destroy()
// page isolation: home untouched
const homeRead = await api('dev:alice', `/api/docs/${proj.id}`)
assert.ok(!homeRead.markdown.includes('v2'), 'home page untouched by page suggestion')
// ACL applies to pages; deletion is creator-only and forbidden for home/_structure
const carolPage = await api('dev:carol', `/api/docs/${proj.id}?page=design-notes`)
assert.ok(carolPage.error, 'stranger blocked from page reads')
const delHome = await api('dev:alice', `/api/docs/${proj.id}/pages/home`, { method: 'DELETE' })
assert.ok(delHome.error, 'home page cannot be deleted')
const delPage = await api('dev:bob', `/api/docs/${proj.id}/pages/design-notes`, { method: 'DELETE' })
assert.equal(delPage.ok, true, 'any collaborator can delete a page: ' + JSON.stringify(delPage))
const carolDel = await api('dev:carol', `/api/docs/${proj.id}/pages/home`, { method: 'DELETE' })
assert.ok(carolDel.error, 'strangers still blocked entirely')
const st3 = await api('dev:alice', `/api/docs/${proj.id}/structure`)
assert.ok(!JSON.stringify(st3.tree).includes('design-notes'), 'deleted page leaves the tree')
await api('dev:alice', `/api/docs/${proj.id}`, { method: 'DELETE' })
console.log('✓ multi-page projects: pages CRUD, structure-as-suggestion, isolation, ACL')
// 11d2. sidebar structure ops: groups + drag-and-drop move endpoints
const sproj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Structure Smoke' } })
await api('dev:alice', `/api/docs/${sproj.id}/pages`, { method: 'POST', body: { title: 'Intro' } })
await api('dev:alice', `/api/docs/${sproj.id}/pages`, { method: 'POST', body: { title: 'Setup' } })
const grp = await api('dev:alice', `/api/docs/${sproj.id}/structure/groups`, { method: 'POST', body: { label: 'Getting started', index: 1 } })
assert.equal(grp.ok, true, 'group created: ' + JSON.stringify(grp))
const mv1 = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'intro', parent: ['"Getting started"'], index: 0 } })
assert.equal(mv1.ok, true, 'move into group: ' + JSON.stringify(mv1))
const mv2 = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'setup', parent: ['"Getting started"'], index: 1 } })
assert.equal(mv2.ok, true, 'second move: ' + JSON.stringify(mv2))
const gst = await api('dev:alice', `/api/docs/${sproj.id}/structure`)
const gnode = gst.tree.find(n => n.group === 'Getting started')
assert.deepEqual(gnode?.children?.map(c => c.slug), ['intro', 'setup'], 'pages filed under the group: ' + JSON.stringify(gst.tree))
const ren = await api('dev:alice', `/api/docs/${sproj.id}/structure/groups/rename`, { method: 'POST', body: { path: ['"Getting started"'], label: 'Start here' } })
assert.equal(ren.ok, true, 'group renamed')
const badMove = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'no-such-page', parent: null, index: 0 } })
assert.ok(badMove.error, 'unknown page rejected')
const agentMove = await api(AGENT, `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { node: 'setup', parent: null, index: 0 } })
assert.ok(agentMove.error, 'agents cannot call structure ops: ' + JSON.stringify(agentMove))
const dis = await api('dev:alice', `/api/docs/${sproj.id}/structure/groups/dissolve`, { method: 'POST', body: { path: ['"Start here"'] } })
assert.equal(dis.ok, true, 'group dissolved')
const gst2 = await api('dev:alice', `/api/docs/${sproj.id}/structure`)
assert.deepEqual(gst2.tree.map(n => n.slug), ['home', 'intro', 'setup'], 'children promoted on dissolve: ' + JSON.stringify(gst2.tree))
// multi-select move: both pages under a fresh group in one call
await api('dev:alice', `/api/docs/${sproj.id}/structure/groups`, { method: 'POST', body: { label: 'Docs' } })
const mvN = await api('dev:alice', `/api/docs/${sproj.id}/structure/move`, { method: 'POST', body: { nodes: ['intro', 'setup'], parent: ['"Docs"'], index: 0 } })
assert.equal(mvN.ok, true, 'multi move: ' + JSON.stringify(mvN))
const gst3 = await api('dev:alice', `/api/docs/${sproj.id}/structure`)
assert.deepEqual(gst3.tree.find(n => n.group === 'Docs')?.children?.map(c => c.slug), ['intro', 'setup'], 'block landed: ' + JSON.stringify(gst3.tree))
// untitled page: empty title allowed, H1 empty, sidebar says Untitled
const upg = await api('dev:alice', `/api/docs/${sproj.id}/pages`, { method: 'POST', body: { title: '' } })
assert.equal(upg.slug, 'untitled', 'untitled slug: ' + JSON.stringify(upg))
const uread = await api('dev:alice', `/api/docs/${sproj.id}?page=untitled`)
assert.ok(uread.pages.find(p => p.slug === 'untitled')?.title === 'Untitled', 'untitled page listed as Untitled')
// rename rewrites the H1 and the sidebar label in one move
const ren2 = await api('dev:alice', `/api/docs/${sproj.id}/pages/untitled/rename`, { method: 'POST', body: { title: 'Field Notes' } })
assert.equal(ren2.ok, true, 'rename: ' + JSON.stringify(ren2))
const rread = await api('dev:alice', `/api/docs/${sproj.id}?page=untitled`)
assert.ok(rread.markdown.startsWith('# Field Notes'), 'H1 rewritten: ' + JSON.stringify(rread.markdown.slice(0, 40)))
assert.equal(rread.pages.find(p => p.slug === 'untitled')?.title, 'Field Notes', 'sidebar title follows')
// renaming home renames the project
await api('dev:alice', `/api/docs/${sproj.id}/pages/home/rename`, { method: 'POST', body: { title: 'Structure Smoke 2' } })
const hread = await api('dev:alice', `/api/docs/${sproj.id}`)
assert.equal(hread.title, 'Structure Smoke 2', 'project title follows home rename: ' + hread.title)
const renAgent = await api(AGENT, `/api/docs/${sproj.id}/pages/untitled/rename`, { method: 'POST', body: { title: 'nope' } })
assert.ok(renAgent.error, 'agents cannot rename')
await api('dev:alice', `/api/docs/${sproj.id}`, { method: 'DELETE' })
console.log('✓ sidebar structure ops: group CRUD, single+multi move, untitled create, H1 rename, agent-blocked')
// 11e. playground: seed, flag, reset restores, creator-only
const pgw = await api('dev:alice', '/api/playground', { method: 'POST' })
const pgDoc = await api('dev:alice', `/api/docs/${pgw.id}`)
assert.ok(pgDoc.playground, 'playground flag set')
const seededCount = pgDoc.suggestions.length
assert.ok(seededCount >= 10, 'playground richly seeded: ' + seededCount)
assert.ok(pgDoc.suggestions.some(s => s.status === 'open'), 'open suggestions present')
const anyOpen = pgDoc.suggestions.find(s => s.status === 'open')
await api('dev:alice', `/api/docs/${pgw.id}/suggestions/${anyOpen.id}/accept`, { method: 'POST' })
const rst = await api('dev:alice', `/api/docs/${pgw.id}/reset`, { method: 'POST' })
assert.equal(rst.ok, true, 'reset ok')
const pgDoc2 = await api('dev:alice', `/api/docs/${pgw.id}`)
assert.equal(pgDoc2.suggestions.length, seededCount, 'reset restores the exact seed (no duplication)')
assert.ok(pgDoc2.markdown.includes('happy testing'), 'reset restores content')
const bobReset = await api('dev:bob', `/api/docs/${pgw.id}/reset`, { method: 'POST' })
assert.ok(bobReset.error, 'reset is creator-only')
const again2 = await api('dev:alice', '/api/playground', { method: 'POST' })
assert.equal(again2.id, pgw.id, 'playground create is idempotent')
await api('dev:alice', `/api/docs/${pgw.id}`, { method: 'DELETE' })
console.log('✓ playground: seed, reset restores state, creator-only')
// 11e-bis. a BRAND NEW agent is unshared: the old code stored no flag and every
// read treated "no flag" as shared, so registering an agent quietly exposed it
// to every collaborator on every doc you share
{
const fresh = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'fresh-agent' } })
assert.ok(fresh.key, 'registered: ' + JSON.stringify(fresh))
const seenByOwner = await api('dev:bob', `/api/agents?doc=${docId}`)
const seenByOther = await api('dev:alice', `/api/agents?doc=${docId}`)
assert.ok(seenByOwner.agents.some(a => a.handle === 'fresh-agent' && a.shared === false), 'owner sees it, marked unshared')
assert.ok(!seenByOther.agents.some(a => a.handle === 'fresh-agent'), 'a collaborator cannot see a newly registered agent')
// and a collaborator's mention must not reach it
const freshThread = 'thf-' + Math.random().toString(36).slice(2, 8)
alice.doc.transact(() => {
const messages = new Y.Array()
messages.push([{ id: 'freshmsg1', author: 'alice', authorType: 'user', text: '@fresh-agent default-privacy check', ts: Date.now() }])
const t = new Y.Map()
t.set('anchorStart', anchorStart)
t.set('anchorEnd', anchorEnd)
t.set('resolved', false)
t.set('messages', messages)
alice.doc.getMap('threads').set(freshThread, t)
})
await new Promise(r => setTimeout(r, 1200))
const snapFresh = await api(fresh.key, '/api/mentions')
assert.ok(!(snapFresh.mentions || []).some(m => m.instruction?.includes('default-privacy check')), 'no work queued from a collaborator: ' + JSON.stringify(snapFresh.mentions))
// sharing is the deliberate act, and it works
const shareIt = await api('dev:bob', '/api/agents/fresh-agent/visibility', { method: 'POST', body: { shared: true } })
assert.equal(shareIt.shared, true, 'owner shared it')
const nowSeen = await api('dev:alice', `/api/agents?doc=${docId}`)
assert.ok(nowSeen.agents.some(a => a.handle === 'fresh-agent'), 'shared agent becomes visible')
alice.doc.transact(() => {
alice.doc.getMap('threads').get(freshThread).get('messages').push([{ id: 'freshmsg2', author: 'alice', authorType: 'user', text: '@fresh-agent now that it is shared', ts: Date.now() }])
})
await waitFor(async () => ((await api(fresh.key, '/api/mentions')).mentions || []).some(m => m.instruction?.includes('now that it is shared')), 'a shared agent does receive collaborator mentions')
await api('dev:bob', '/api/agents/fresh-agent', { method: 'DELETE' })
console.log('✓ a new agent is unshared until its owner shares it')
}
// 11f. private agents: only the owner's mentions reach them
const visPriv = await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: false } })
assert.equal(visPriv.shared, false, 'owner made agent private')
const aliceAgents = await api('dev:alice', `/api/agents?doc=${docId}`)
assert.ok(!aliceAgents.agents.some(a => a.handle === 'test-agent'), 'private agent hidden from collaborators')
const bobAgents = await api('dev:bob', `/api/agents?doc=${docId}`)
assert.ok(bobAgents.agents.some(a => a.handle === 'test-agent'), 'owner still sees it')
// alice mentions it: no task; bob mentions it: task created
const privThread = 'thp-' + Math.random().toString(36).slice(2, 8)
alice.doc.transact(() => {
const messages = new Y.Array()
messages.push([{ id: 'privmsg1', author: 'alice', authorType: 'user', text: '@test-agent private check from alice', ts: Date.now() }])
const t = new Y.Map()
t.set('anchorStart', anchorStart)
t.set('anchorEnd', anchorEnd)
t.set('resolved', false)
t.set('messages', messages)
alice.doc.getMap('threads').set(privThread, t)
})
await new Promise(r => setTimeout(r, 1200))
const snapPriv = await api(AGENT, '/api/mentions')
assert.ok(!snapPriv.mentions.some(m => m.instruction?.includes('private check from alice')), 'collaborator mention does NOT reach a private agent')
alice.doc.transact(() => {
alice.doc.getMap('threads').get(privThread).get('messages').push([{ id: 'privmsg2', author: 'bob', authorType: 'user', text: '@test-agent private check from bob', ts: Date.now() }])
})
await waitFor(async () => (await api(AGENT, '/api/mentions')).mentions.some(m => m.instruction?.includes('private check from bob')), 'owner mention reaches private agent')
await api('dev:bob', '/api/agents/test-agent/visibility', { method: 'POST', body: { shared: true } })
console.log('✓ private agents: hidden from collaborators, owner-only mentions')
// 11g. task lists + GFM tables round-trip through an agent suggestion + accept
const ttProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'TT Smoke' } })
await api('dev:alice', `/api/docs/${ttProj.id}/share`, { method: 'POST', body: { username: 'bob' } })
const ttSnap = await api(AGENT, `/api/docs/${ttProj.id}`)
const ttSug = await api(AGENT, `/api/docs/${ttProj.id}/suggestions`, {
method: 'POST',
body: {
block_index: ttSnap.blocks.length - 1,
replacement_markdown: '- [ ] open task\n- [x] done task\n\n| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n| Bob | Writer |',
},
})
assert.ok(ttSug.ok, 'todo+table suggestion: ' + JSON.stringify(ttSug))
const ttAcc = await api('dev:alice', `/api/docs/${ttProj.id}/suggestions/${ttSug.suggestion_id}/accept`, { method: 'POST' })
assert.equal(ttAcc.ok, true, 'accept: ' + JSON.stringify(ttAcc))
await waitFor(async () => (await api('dev:alice', `/api/docs/${ttProj.id}`)).markdown.includes('| --- |'), 'table applied')
const ttMd = (await api('dev:alice', `/api/docs/${ttProj.id}`)).markdown
assert.ok(ttMd.includes('- [ ] open task') && ttMd.includes('- [x] done task'), 'task list round-trips: ' + ttMd)
assert.ok(ttMd.includes('| Name | Role |') && ttMd.includes('| Ada | Engineer |'), 'GFM table round-trips: ' + ttMd)
await api('dev:alice', `/api/docs/${ttProj.id}`, { method: 'DELETE' })
console.log('✓ task lists + GFM tables: agent suggestion, accept, markdown round-trip')
// 11g-math. LaTeX survives the whole agent path: markdown -> Yjs -> markdown
const mProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Math Smoke' } })
await api('dev:alice', `/api/docs/${mProj.id}/share`, { method: 'POST', body: { username: 'bob' } })
const mSnap = await api(AGENT, `/api/docs/${mProj.id}`)
assert.ok(mSnap.blocks, 'agent can read the math project: ' + JSON.stringify(mSnap))
const mathMd = [
'The bound $\\|Ax - b\\|_2^2$ is tight when $a * b$ and $c * d$ agree.',
'',
'$$',
'\\int_0^\\infty e^{-x} dx = 1',
'$$',
'',
'| term | value |',
'| --- | --- |',
'| $e^{i\\pi}$ | $-1$ |',
'',
'It costs $5 and $10 more.',
].join('\n')
const mSug = await api(AGENT, `/api/docs/${mProj.id}/suggestions`, {
method: 'POST',
body: { block_index: mSnap.blocks.length - 1, replacement_markdown: mathMd },
})
assert.ok(mSug.ok, 'math suggestion accepted by the API: ' + JSON.stringify(mSug))
const mAcc = await api('dev:alice', `/api/docs/${mProj.id}/suggestions/${mSug.suggestion_id}/accept`, { method: 'POST' })
assert.equal(mAcc.ok, true, 'accept math: ' + JSON.stringify(mAcc))
await waitFor(async () => (await api('dev:alice', `/api/docs/${mProj.id}`)).markdown.includes('e^{i\\pi}'), 'math applied')
const mOut = (await api('dev:alice', `/api/docs/${mProj.id}`)).markdown
assert.ok(mOut.includes('$\\|Ax - b\\|_2^2$'), 'inline math round-trips: ' + mOut)
// the two formulas that markdown emphasis used to swallow
assert.ok(mOut.includes('$a * b$') && mOut.includes('$c * d$'), 'stars inside math survive: ' + mOut)
assert.ok(/\$\$\n\\int_0\^\\infty e\^\{-x\} dx = 1\n\$\$/.test(mOut), 'display math round-trips fenced: ' + mOut)
assert.ok(mOut.includes('| $e^{i\\pi}$ | $-1$ |'), 'math inside a table cell round-trips: ' + mOut)
assert.ok(mOut.includes('costs $5 and $10 more'), 'currency is not treated as math: ' + mOut)
await api('dev:alice', `/api/docs/${mProj.id}`, { method: 'DELETE' })
console.log('✓ LaTeX math: agent suggestion, accept, markdown round-trip (inline, display, table, currency)')
// 11h. new-page suggestions: agent npProposes a whole page, human accepts/rejects
const npjProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'NP Wiki' } })
await api('dev:alice', `/api/docs/${npjProj.id}/share`, { method: 'POST', body: { username: 'bob' } })
const npProp = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: 'Roadmap', content_markdown: '# Roadmap\n\n- [ ] q1 goal\n\n| Item | State |\n| --- | --- |\n| A | todo |', rationale: 'draft the roadmap' },
})
assert.ok(npProp.ok && npProp.slug === 'roadmap', 'npProposal created: ' + JSON.stringify(npProp))
const npSt = await api('dev:alice', `/api/docs/${npjProj.id}/structure`)
assert.ok(npSt.pending?.some(p => p.slug === 'roadmap' && p.author === 'test-agent'), 'pending appears in structure: ' + JSON.stringify(npSt.pending))
// page must not exist yet
assert.ok(!(await api('dev:alice', `/api/docs/${npjProj.id}`)).pages.some(p => p.slug === 'roadmap'), 'page not created before accept')
// agent key cannot accept (requireHuman)
const npAgentAccept = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions/${npProp.page_suggestion_id}/accept`, { method: 'POST' })
assert.ok(npAgentAccept.error, 'agent key cannot accept a page npProposal')
// human accepts -> page created with content + added to structure
const npAcc = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${npProp.page_suggestion_id}/accept`, { method: 'POST' })
assert.equal(npAcc.ok, true, 'accept: ' + JSON.stringify(npAcc))
const npPageMd = (await api('dev:alice', `/api/docs/${npjProj.id}?page=roadmap`)).markdown
assert.ok(npPageMd.includes('- [ ] q1 goal') && npPageMd.includes('| Item | State |'), 'accepted page has the npProposed content: ' + npPageMd)
const npSt2 = await api('dev:alice', `/api/docs/${npjProj.id}/structure`)
assert.ok(npSt2.tree.some(n => n.slug === 'roadmap'), 'accepted page joins the tree')
assert.equal(npSt2.pending?.length || 0, 0, 'no longer pending')
// reject flow
const npProp2 = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { method: 'POST', body: { title: 'Scratch', content_markdown: '# Scratch' } })
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${npProp2.page_suggestion_id}/reject`, { method: 'POST' })
const npSt3 = await api('dev:alice', `/api/docs/${npjProj.id}/structure`)
assert.equal(npSt3.pending?.length || 0, 0, 'rejected npProposal cleared')
assert.ok(!(await api('dev:alice', `/api/docs/${npjProj.id}`)).pages.some(p => p.slug === 'scratch'), 'rejected page never created')
// 11h-2. the page body must never be dropped in silence. Reading only the
// exact field name meant an agent that sent "markdown" got a 200 and a
// title-only page — no error, nothing to retry against. (Reported from the
// field: "accepts a markdown field but ignores it, so both pages arrived
// title-only".)
for (const field of ['markdown', 'content', 'content_markdown']) {
const prop = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: `Via ${field}`, [field]: `# Via ${field}\n\nBody sent as ${field}.` },
})
assert.ok(prop.ok, `a body under "${field}" is accepted: ${JSON.stringify(prop)}`)
const stored = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${prop.page_suggestion_id}`)
assert.ok(
stored.content_markdown.includes(`Body sent as ${field}`),
`a body under "${field}" survives instead of vanishing: ${JSON.stringify(stored.content_markdown)}`
)
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${prop.page_suggestion_id}/reject`, { method: 'POST' })
}
// and with no body at all it is an error, not an empty page
const noBody = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, { method: 'POST', body: { title: 'Bodyless', rationale: 'oops' } })
assert.ok(noBody.error?.includes('content_markdown'), 'a proposal with no body is refused: ' + JSON.stringify(noBody))
assert.ok(!noBody.page_suggestion_id, 'and no title-only page proposal is left behind')
assert.equal(
(await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).pending?.length || 0,
0,
'nothing pending after the refusals'
)
// the same tolerance on ordinary suggestions, where '' still means "delete this"
const sugTol = await api(AGENT, `/api/docs/${npjProj.id}/suggestions`, { method: 'POST', body: { block_index: 0, markdown: 'replaced via alias' } })
assert.ok(sugTol.ok, 'a suggestion body under "markdown" is accepted too: ' + JSON.stringify(sugTol))
const sugNone = await api(AGENT, `/api/docs/${npjProj.id}/suggestions`, { method: 'POST', body: { block_index: 0 } })
assert.ok(sugNone.error?.includes('replacement_markdown'), 'a suggestion with no body is still refused: ' + JSON.stringify(sugNone))
console.log('✓ page/suggestion bodies: field-name aliases accepted, a missing body is an error not an empty page')
// 11h-3. a proposal can say WHERE in the hierarchy the page belongs, in the
// same {parent, index} vocabulary /structure/move uses — an agent proposing a
// page had no way to place it, so every accepted page landed at the top level.
await api('dev:alice', `/api/docs/${npjProj.id}/pages`, { method: 'POST', body: { title: 'Research' } })
await api('dev:alice', `/api/docs/${npjProj.id}/structure/groups`, { method: 'POST', body: { label: 'Getting started' } })
const inGroup = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: 'Setup', content_markdown: '# Setup\n\nInstall it.', parent: ['"Getting started"'], index: 0 },
})
assert.ok(inGroup.ok, 'a proposal into a group is accepted: ' + JSON.stringify(inGroup))
const underPage = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: 'Notes on X', content_markdown: '# Notes\n\n...', parent: 'research' },
})
assert.ok(underPage.ok, 'a proposal under a page is accepted: ' + JSON.stringify(underPage))
// a parent nobody can find is refused, not silently flattened to the root
const badParent = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: 'Nope', content_markdown: '# Nope', parent: 'no-such-page' },
})
assert.ok(badParent.error?.includes('unknown parent'), 'an unknown parent is refused: ' + JSON.stringify(badParent))
// the placement reaches the sidebar payload, so a pending page can be drawn in place
const placedPending = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).pending
const setupPending = placedPending.find(p => p.title === 'Setup')
assert.deepEqual(setupPending.parent, ['"Getting started"'], 'pending carries its parent: ' + JSON.stringify(setupPending))
assert.equal(setupPending.index, 0, 'and its index')
assert.equal(placedPending.find(p => p.title === 'Notes on X').parent, 'research', 'a page parent survives as a slug')
// and accepting files the page there, instead of appending at the top level
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${inGroup.page_suggestion_id}/accept`, { method: 'POST' })
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${underPage.page_suggestion_id}/accept`, { method: 'POST' })
const placedTree = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).tree
const groupNode = placedTree.find(n => n.group === 'Getting started')
assert.deepEqual(
groupNode?.children?.map(c => c.slug),
['setup'],
'accepted page sits inside the group it was proposed for: ' + JSON.stringify(placedTree)
)
assert.deepEqual(
placedTree.find(n => n.slug === 'research')?.children?.map(c => c.slug),
['notes-on-x'],
'and a page-parented proposal nests under that page: ' + JSON.stringify(placedTree)
)
// An agent works from what it can see, and what it sees is a TITLE. Sending
// "Research Notes" for the page `research-notes`, or a group's label without
// its quotes, used to be a 400 — which is how a requested subpage ended up at
// the top level instead.
await api('dev:alice', `/api/docs/${npjProj.id}/pages`, { method: 'POST', body: { title: 'Research Notes' } })
const guesses = [
['the exact slug', 'research-notes', 'research-notes'],
['the page title', 'Research Notes', 'research-notes'],
['the title in another case', 'research notes', 'research-notes'],
['a group label without quotes', 'Getting started', ['"Getting started"']],
['a one-element array holding a title', ['Research Notes'], 'research-notes'],
]
for (const [what, parent, expected] of guesses) {
const r = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: `Placed by ${what}`, content_markdown: '# X', parent },
})
assert.ok(r.ok, `${what} is accepted as a parent: ${JSON.stringify(r)}`)
assert.deepEqual(r.parent, expected, `${what} resolves to the canonical ref, and the response says so: ${JSON.stringify(r)}`)
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${r.page_suggestion_id}/reject`, { method: 'POST' })
}
// the aliases an agent is likely to reach for
for (const field of ['parent_slug', 'parent_page', 'under']) {
const r = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: `Via ${field}`, content_markdown: '# X', [field]: 'Research Notes' },
})
assert.equal(r.parent, 'research-notes', `"${field}" works as a parent field: ${JSON.stringify(r)}`)
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${r.page_suggestion_id}/reject`, { method: 'POST' })
}
// a name nothing matches is still refused, with the pages listed to choose from
const noSuch = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: 'Nowhere', content_markdown: '# X', parent: 'Marketing Plan' },
})
assert.ok(noSuch.error?.includes('unknown parent'), 'an unknown parent is refused: ' + JSON.stringify(noSuch))
assert.ok(noSuch.pages?.includes('research-notes'), 'and the reply lists real pages to choose from: ' + JSON.stringify(noSuch))
// reading a proposal back shows where it is headed
const titleParented = await api(AGENT, `/api/docs/${npjProj.id}/page-suggestions`, {
method: 'POST',
body: { title: 'Child By Title', content_markdown: '# Child', parent: 'Research Notes' },
})
const readBack = await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${titleParented.page_suggestion_id}`)
assert.equal(readBack.parent, 'research-notes', 'GET shows the resolved parent: ' + JSON.stringify(readBack))
await api('dev:alice', `/api/docs/${npjProj.id}/page-suggestions/${titleParented.page_suggestion_id}/accept`, { method: 'POST' })
const guessTree = (await api('dev:alice', `/api/docs/${npjProj.id}/structure`)).tree
assert.deepEqual(
guessTree.find(n => n.slug === 'research-notes')?.children?.map(c => c.slug),
['child-by-title'],
'and a title-parented proposal lands under that page: ' + JSON.stringify(guessTree)
)
console.log('✓ a parent can be named by slug, title or group label — an agent\'s guess is not silently flattened')
console.log('✓ page proposals carry a place in the hierarchy, and accepting files them there')
// 11i. markdown export: a zip that renders outside cowrite. The images matter
// most — a /files/... link only resolves for someone signed in here, so the
// bytes have to travel with the markdown and the links have to be rewritten.
const expProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Export Me' } })
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8ffff3f0005fe02fea735c9080000000049454e44ae426082',
'hex'
)
const upRes = await fetch(`${BASE}/api/docs/${expProj.id}/upload`, {
method: 'POST',
headers: { authorization: 'Bearer dev:alice', 'content-type': 'image/png' },
body: png,
}).then(r => r.json())
assert.ok(upRes.url?.startsWith('/files/'), 'image uploaded: ' + JSON.stringify(upRes))
await api('dev:alice', `/api/docs/${expProj.id}/pages`, {
method: 'POST',
body: { title: 'Figures', content_markdown: `# Figures\n\nSee it:\n\n![shot](${upRes.url})` },
})
await api('dev:alice', `/api/docs/${expProj.id}/pages`, { method: 'POST', body: { title: 'Prose', content_markdown: '# Prose\n\nNo pictures.' } })
const getZip = async query => {
const r = await fetch(`${BASE}/api/docs/${expProj.id}/export${query}`, { headers: { authorization: 'Bearer dev:alice' } })
return { status: r.status, headers: r.headers, files: readZip(Buffer.from(await r.arrayBuffer())) }
}
const whole = await getZip('?scope=project')
assert.equal(whole.status, 200)
assert.equal(whole.headers.get('content-type'), 'application/zip')
assert.match(whole.headers.get('content-disposition'), /attachment; filename="export-me-project\.zip"/)
const names = Object.keys(whole.files).sort()
assert.deepEqual(
names.filter(n => n.endsWith('.md')).sort(),
['figures.md', 'home.md', 'prose.md'],
'one .md per page: ' + JSON.stringify(names)
)
assert.ok(names.includes('structure.yaml'), 'the project shape travels too: ' + JSON.stringify(names))
const assetName = names.find(n => n.startsWith('assets/') && n.endsWith('.png'))
assert.ok(assetName, 'the referenced image is in the zip: ' + JSON.stringify(names))
assert.ok(whole.files[assetName].equals(png), 'and it is the real bytes, not a placeholder')
const figuresMd = whole.files['figures.md'].toString()
assert.ok(figuresMd.includes(`](${assetName})`), 'the image link is rewritten to the bundled copy: ' + figuresMd)
assert.ok(!figuresMd.includes('/files/'), 'no authenticated /files link is left behind: ' + figuresMd)
const onePage = await getZip('?scope=page&page=prose')
assert.deepEqual(Object.keys(onePage.files), ['prose.md'], 'page scope carries just that page (and no assets it does not use)')
assert.match(onePage.headers.get('content-disposition'), /filename="prose\.zip"/)
const pageWithImage = await getZip('?scope=page&page=figures')
assert.ok(Object.keys(pageWithImage.files).some(n => n.endsWith('.png')), 'a single page still brings its own images')
// a bad page slug is a 404, not an empty archive; and no access means no export
assert.equal((await fetch(`${BASE}/api/docs/${expProj.id}/export?scope=page&page=nope`, { headers: { authorization: 'Bearer dev:alice' } })).status, 404)
assert.equal((await fetch(`${BASE}/api/docs/${expProj.id}/export`, { headers: { authorization: 'Bearer dev:carol' } })).status, 404)
await api('dev:alice', `/api/docs/${expProj.id}`, { method: 'DELETE' })
console.log('✓ markdown export: one .md per page, images bundled and relinked, scoped to page or project')
await api('dev:alice', `/api/docs/${npjProj.id}`, { method: 'DELETE' })
console.log('✓ new-page suggestions: propose, pending, human accept creates page, reject discards')
// 12. persistence across restart (registry keeps ACL)
alice.provider.destroy()
await stopServer()
// Upgrade fixture: old releases persisted each subpage in its own Yjs file.
// The first project open must copy those fields into the base doc and leave
// the source files untouched so rolling back remains possible.
const legacyId = 'legacy-unified-fixture'
const registryPath = path.join(DATA, 'registry.json')
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'))
registry[legacyId] = {
title: 'Legacy Project', createdBy: 'alice', createdAt: Date.now(), updatedAt: Date.now(),
pages: { notes: { title: 'Legacy Notes' }, _structure: { title: 'Structure' } },
}
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2))
const docsDir = path.join(DATA, 'docs')
writeLegacyProjectDoc(path.join(docsDir, `${legacyId}.yjs`), [legacyTextBlock('heading', 'Legacy Project', { level: 1 })])
writeLegacyProjectDoc(
path.join(docsDir, `${legacyId}__notes.yjs`),
[legacyTextBlock('heading', 'Legacy Notes', { level: 1 }), legacyTextBlock('paragraph', 'Imported page body.')],
[{ id: 'legacy-suggestion', author: 'old-agent', status: 'open', rationale: 'preserve me' }]
)
writeLegacyProjectDoc(
path.join(docsDir, `${legacyId}___structure.yjs`),
[legacyTextBlock('codeBlock', '- home\n - notes', { language: 'yaml' })]
)
await startServer()
const after = await api('dev:bob', `/api/docs/${docId}`)
assert.ok(after.markdown.includes('improved'), 'content + share survived restart')
assert.deepEqual(after.shared_with, ['bob'], 'ACL persisted')
const afterKey = await api(AGENT, `/api/docs/${docId}`)
assert.ok(afterKey.markdown, 'agent key survives restart')
console.log('✓ persistence across restart (content, ACL, agent key)')
const imported = await api('dev:alice', `/api/docs/${legacyId}?page=notes`)
assert.ok(imported.markdown.includes('Imported page body.'), 'legacy subpage content imported')
assert.ok(imported.suggestions.some(s => s.id === 'legacy-suggestion'), 'legacy page metadata imported')
const importedStructure = await api('dev:alice', `/api/docs/${legacyId}/structure`)
assert.equal(importedStructure.tree[0]?.children?.[0]?.slug, 'notes', 'legacy structure hierarchy imported')
assert.ok(fs.existsSync(path.join(docsDir, `${legacyId}__notes.yjs`)), 'legacy source page retained for rollback')
await api('dev:alice', `/api/docs/${legacyId}`, { method: 'DELETE' })
console.log('✓ legacy page files migrate into one project document and remain rollback-safe')
// 12b. agent removal: strangers can't, owner and admin can; keys die with it
const strangerDel = await api('dev:alice', '/api/agents/test-agent', { method: 'DELETE' })
assert.ok(strangerDel.error, 'non-owner non-admin cannot remove an agent')
const ownerDel = await api('dev:bob', '/api/agents/test-agent', { method: 'DELETE' })
assert.equal(ownerDel.ok, true, 'owner removes own agent')
const deadKey = await fetch(`${BASE}/api/mentions`, { headers: { authorization: `Bearer ${AGENT}` } })
assert.equal(deadKey.status, 401, 'removed agent key is dead')
const reg2 = await api('dev:bob', '/api/agents', { method: 'POST', body: { handle: 'admin-target' } })
assert.equal(reg2.ok, true)
const adminDel = await api('dev:root-admin', '/api/agents/admin-target', { method: 'DELETE' })
assert.equal(adminDel.ok, true, 'space admin can remove any agent: ' + JSON.stringify(adminDel))
console.log('✓ agent removal: owner + admin allowed, strangers blocked, key revoked')
// 13. deletion + auth
const deniedDel = await api('dev:bob', `/api/docs/${docId}`, { method: 'DELETE' })
assert.ok(deniedDel.error, 'non-creator cannot delete')
const deleted = await api('dev:alice', `/api/docs/${docId}`, { method: 'DELETE' })
assert.equal(deleted.ok, true, 'creator deletes doc')
const anon = await fetch(`${BASE}/api/docs`).then(r => r.status)
assert.equal(anon, 401, 'unauthenticated rejected')
console.log('✓ deletion (creator-only) + auth required')
await stopServer()
fs.rmSync(DATA, { recursive: true, force: true })
console.log('\nALL SMOKE TESTS PASSED')
}
main().catch(err => {
console.error('\nSMOKE TEST FAILED:', err)
try { server?.kill('SIGKILL') } catch {}
process.exit(1)
})