// The cluster tools against a real TLSocketRoom on a temp data dir: node --test server/clusterTools.test.ts import type { TLBinding, TLFrameShape, TLRecord, TLShape } from '@tldraw/tlschema' import assert from 'node:assert/strict' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { after, test } from 'node:test' import { clusterShapeId, paperShapeId } from '../shared/ids.ts' import { CARD_SIZE, type PaperCardShape } from '../shared/shapes.ts' import { addNote, addPapers, getBoard, ToolError, type ToolContext } from './canvasTools.ts' import { applyClusters, labelCluster } from './clusterTools.ts' import { centre, isFree, MARGIN, shapeBoxes } from './layout.ts' import type { ClusterResult, PapersClient } from './papersClient.ts' import { closeAllRooms, patchShapes, putShapes, readRoom, setRoomsDir } from './rooms.ts' const dir = mkdtempSync(join(tmpdir(), 'cluster-tools-')) setRoomsDir(dir) after(() => { closeAllRooms() rmSync(dir, { recursive: true, force: true }) }) let clusters: ClusterResult = { clusters: [], similarities: [] } let clusterCalls: string[][] = [] const papers: PapersClient = { url: 'http://papers.test/mcp', close: async () => {}, getPaper: async (paperId) => ({ paperId, title: 'T', abstract: 'A', url: null }), hasTool: async (name) => name === 'cluster_papers', async clusterPapers(paperIds) { clusterCalls.push(paperIds) if (paperIds.includes('down')) throw new Error('The papers MCP at http://papers.test/mcp is unreachable (ECONNREFUSED)') return clusters }, } let boards = 0 const newCtx = (): ToolContext => ({ boardId: `clusters${++boards}`, onBehalfOf: 'm_ana', runId: 'run_0123456789ab', model: null, papers, }) const all = (ctx: ToolContext) => readRoom(ctx.boardId, (txn) => [...txn.values()]) as TLRecord[] const get = (ctx: ToolContext, id: string) => readRoom(ctx.boardId, (txn) => txn.get(id as never)) as T const card = (ctx: ToolContext, paperId: string) => get(ctx, paperShapeId(paperId)) const frame = (ctx: ToolContext, clusterId: string) => get(ctx, clusterShapeId(clusterId)) const paper = (paperId: string, extra = {}) => ({ paperId, title: `Paper ${paperId}`, ...extra }) const rejects = (fn: () => unknown, pattern: RegExp) => assert.rejects(async () => fn(), (e: Error) => e instanceof ToolError && pattern.test(e.message)) // every box on the page, and every box inside each frame, keeps the layout margin from its neighbours function assertNoOverlaps(ctx: ToolContext) { const records = all(ctx) const boxes = shapeBoxes(records) const byParent = new Map() for (const r of records) { if (r.typeName !== 'shape' || !boxes.has(r.id)) continue byParent.set(r.parentId, [...(byParent.get(r.parentId) ?? []), r.id]) } for (const ids of byParent.values()) { ids.forEach((id, i) => { const others = ids.slice(0, i).map((o) => boxes.get(o)!) assert.ok(isFree(others, boxes.get(id)!), `${id} overlaps a sibling`) }) } } test('apply_clusters puts the agent’s papers into named frames and leaves people’s papers alone', async () => { const ctx = newCtx() addPapers(ctx, { papers: ['a', 'b', 'c', 'd', 'e', 'moved'].map((id) => paper(id)) }) putShapes(ctx.boardId, [ { id: paperShapeId('mine'), type: 'paper-card', x: -400, y: 0, props: { ...card(ctx, 'a').props, paperId: 'mine' }, meta: { author: 'm_ben' } }, ]) patchShapes(ctx.boardId, [{ id: card(ctx, 'moved').id, x: 900, y: 900, meta: { pinnedByHuman: true } }]) const before = { mine: card(ctx, 'mine'), moved: card(ctx, 'moved') } clusters = { clusters: [ { clusterId: 'c1', paperIds: ['a', 'b', 'c', 'mine'], label: 'T-cell states' }, { clusterId: 'c2', paperIds: ['d', 'e', 'moved', 'not-here'], label: null }, ], similarities: [{ a: 'c', b: 'b', similarity: 0.9 }], } const result = await applyClusters(ctx, {}) assert.deepEqual(clusterCalls.at(-1)?.sort(), ['a', 'b', 'c', 'd', 'e', 'mine', 'moved']) assert.deepEqual(result.clusters, [ { clusterId: 'c1', label: 'T-cell states', paperIds: ['a', 'b', 'c'] }, { clusterId: 'c2', label: '', paperIds: ['d', 'e'] }, ]) assert.deepEqual(result.skipped, [ { paperId: 'mine', reason: 'made by a person' }, { paperId: 'moved', reason: 'moved by a person' }, { paperId: 'not-here', reason: 'no card on this board' }, ]) const c1 = frame(ctx, 'c1') assert.equal(c1.type, 'frame') assert.equal(c1.props.name, 'T-cell states') assert.deepEqual(c1.meta, { createdBy: 'agent', onBehalfOf: 'm_ana', runId: 'run_0123456789ab', clusterId: 'c1', labelGenerated: true, renamedByHuman: false, }) assert.equal(frame(ctx, 'c2').meta.labelGenerated, false) for (const id of ['a', 'b', 'c']) assert.equal(card(ctx, id).parentId, c1.id) // the most similar papers come first in the grid assert.ok(card(ctx, 'b').x < card(ctx, 'a').x || card(ctx, 'b').y < card(ctx, 'a').y) // people's papers keep their place assert.deepEqual(card(ctx, 'mine'), before.mine) assert.deepEqual(card(ctx, 'moved'), before.moved) // frames sit under everything else on the page, so links stay visible const pageShapes = all(ctx).filter((r): r is TLShape => r.typeName === 'shape' && r.parentId === c1.parentId) const frames = pageShapes.filter((s) => s.type === 'frame').map((s) => s.index) const rest = pageShapes.filter((s) => s.type !== 'frame').map((s) => s.index) assert.ok(frames.every((f) => rest.every((r) => f < r))) assertNoOverlaps(ctx) const board = getBoard(ctx) assert.deepEqual(board.clusters, [ { clusterId: 'c1', label: 'T-cell states', labelGenerated: true, humanTouched: false }, { clusterId: 'c2', label: '', labelGenerated: false, humanTouched: false }, ]) assert.equal(board.papers.find((p) => p.paperId === 'a')?.cluster, 'c1') assert.equal(board.papers.find((p) => p.paperId === 'mine')?.cluster, null) // running it again moves nothing const cards = ['a', 'b', 'c', 'd', 'e'].map((id) => card(ctx, id)) const again = await applyClusters(ctx, { paperIds: ['a', 'b', 'c', 'd', 'e'] }) assert.deepEqual(again.clusters, result.clusters) assert.deepEqual(['a', 'b', 'c', 'd', 'e'].map((id) => card(ctx, id)), cards) }) test('apply_clusters fits nine cards in a frame: none overlap as drawn and each is inside the frame’s inner box', async () => { const ctx = newCtx() const ids = Array.from({ length: 9 }, (_, i) => `p${i}`) addPapers(ctx, { papers: ids.map((id) => paper(id)) }) clusters = { clusters: [{ clusterId: 'c9', paperIds: ids, label: 'Nine' }], similarities: [] } await applyClusters(ctx, {}) const f = frame(ctx, 'c9') const inner = { x: f.x + MARGIN, y: f.y + MARGIN, w: f.props.w - 2 * MARGIN, h: f.props.h - 2 * MARGIN } // what the browser draws: a CARD_SIZE card centred on each node const drawn = ids.map((id) => { const c = card(ctx, id) assert.equal(c.parentId, f.id) const [x, y] = [f.x + c.x + c.props.w / 2 - CARD_SIZE.w / 2, f.y + c.y + c.props.h / 2 - CARD_SIZE.h / 2] return { x, y, w: CARD_SIZE.w, h: CARD_SIZE.h } }) for (const b of drawn) { assert.ok(b.x >= inner.x && b.y >= inner.y && b.x + b.w <= inner.x + inner.w && b.y + b.h <= inner.y + inner.h) } drawn.forEach((b, i) => assert.ok(isFree(drawn.slice(0, i), b), `${ids[i]} overlaps another card`)) assertNoOverlaps(ctx) }) test('add_papers near a clustered paper, or near a cluster, joins it; a full frame grows unless a person edited it', async () => { const ctx = newCtx() addPapers(ctx, { papers: [paper('a'), paper('b')] }) clusters = { clusters: [{ clusterId: 'c1', paperIds: ['a', 'b'], label: 'Atlas' }], similarities: [] } await applyClusters(ctx, {}) const c1 = frame(ctx, 'c1') addPapers(ctx, { papers: [paper('n1', { near: 'a' }), paper('n2', { near: 'c1' })] }) assert.equal(card(ctx, 'n1').parentId, c1.id) assert.equal(card(ctx, 'n2').parentId, c1.id) // many more: the agent's frame grows to take them all addPapers(ctx, { papers: Array.from({ length: 8 }, (_, i) => paper(`m${i}`, { near: 'a' })) }) for (let i = 0; i < 8; i++) assert.equal(card(ctx, `m${i}`).parentId, c1.id, `m${i}`) assert.ok(frame(ctx, 'c1').props.h > c1.props.h) assert.equal(frame(ctx, 'c1').x, c1.x) assert.equal(frame(ctx, 'c1').y, c1.y) assertNoOverlaps(ctx) // once a person resized it, it keeps its size and new papers go beside it on the page patchShapes(ctx.boardId, [{ id: c1.id, meta: { editedByHuman: true } }]) const size = frame(ctx, 'c1').props addPapers(ctx, { papers: Array.from({ length: 6 }, (_, i) => paper(`o${i}`, { near: 'a' })) }) assert.deepEqual(frame(ctx, 'c1').props, size) assert.ok(['o0', 'o1', 'o2', 'o3', 'o4', 'o5'].some((id) => card(ctx, id).parentId !== c1.id)) assertNoOverlaps(ctx) }) test('notes pinned to a clustered paper go beside it, outside the frame; a person’s note keeps its paper put', async () => { const ctx = newCtx() addPapers(ctx, { papers: ['a', 'b', 'c'].map((id) => paper(id)) }) const { shapeId } = addNote(ctx, { text: 'Read the methods', paperId: 'a' }) const people = { ...card(ctx, 'c') } // 24 px right of the drawn card const dx = people.props.w / 2 + CARD_SIZE.w / 2 + 24 putShapes(ctx.boardId, [ { id: 'shape:their-note', type: 'note', x: people.x + dx, y: people.y, props: get(ctx, shapeId).props, meta: { author: 'm_ben' } }, { typeName: 'binding', id: 'binding:their-pin', type: 'note-pin', fromId: 'shape:their-note', toId: people.id, props: { dx, dy: 0 }, meta: {} }, ]) clusters = { clusters: [{ clusterId: 'c1', paperIds: ['a', 'b', 'c'], label: 'Methods' }], similarities: [] } const result = await applyClusters(ctx, {}) assert.deepEqual(result.skipped, [{ paperId: 'c', reason: 'a person’s note is pinned to it' }]) assert.deepEqual(card(ctx, 'c'), people) const boxes = shapeBoxes(all(ctx)) const note = boxes.get(shapeId)! const frameBox = boxes.get(clusterShapeId('c1'))! assert.ok(isFree([frameBox], note), 'the note sits outside the frame') const pin = all(ctx).find((r) => r.typeName === 'binding' && r.type === 'note-pin' && r.fromId === shapeId) as TLBinding<'note-pin'> // the pin's offset is from the node's page position, not from the card drawn around it const a = card(ctx, 'a') assert.equal(a.parentId, clusterShapeId('c1')) assert.deepEqual({ dx: note.x - (frameBox.x + a.x), dy: note.y - (frameBox.y + a.y) }, pin.props) const cardBox = boxes.get(a.id)! const [n, c] = [centre(note), centre(cardBox)] assert.ok(Math.hypot(n.x - c.x, n.y - c.y) < 400, 'the note stays beside its paper') assertNoOverlaps(ctx) }) test('label_cluster renames the agent’s clusters only', async () => { const ctx = newCtx() addPapers(ctx, { papers: [paper('a'), paper('b'), paper('c'), paper('d')] }) clusters = { clusters: [ { clusterId: 'c1', paperIds: ['a', 'b'], label: null }, { clusterId: 'c2', paperIds: ['c', 'd'], label: 'Old' }, ], similarities: [], } await applyClusters(ctx, {}) assert.deepEqual(labelCluster(ctx, { clusterId: 'c1', label: 'Checkpoint response' }), { ok: true }) assert.equal(frame(ctx, 'c1').props.name, 'Checkpoint response') assert.equal(frame(ctx, 'c1').meta.labelGenerated, true) patchShapes(ctx.boardId, [{ id: frame(ctx, 'c2').id, props: { name: 'Mine' }, meta: { renamedByHuman: true } }]) assert.deepEqual(labelCluster(ctx, { clusterId: 'c2', label: 'New' }), { ok: false, skipped: 'renamed by a person' }) assert.equal(frame(ctx, 'c2').props.name, 'Mine') // a re-run doesn't rename it either clusters.clusters[1].label = 'Newer' await applyClusters(ctx, {}) assert.equal(frame(ctx, 'c2').props.name, 'Mine') await rejects(() => labelCluster(ctx, { clusterId: 'nope', label: 'X' }), /No cluster nope/) }) // One real cluster_papers result (discovery/tests/test_clusters.py's three groups, k=3, Lightning failing for one) const REAL = { clusters: [ { clusterId: 'c-pmid_101', paperIds: ['pmid:101', 'pmid:100', 'pmid:102', 'pmid:103'], label: 'Tumour ecosystem' }, { clusterId: 'c-pmid_200', paperIds: ['pmid:200', 'pmid:202', 'pmid:201', 'pmid:203'], label: 'T cell exhaustion' }, { clusterId: 'c-pmid_300', paperIds: ['pmid:300', 'pmid:303', 'pmid:302', 'pmid:301'], label: null }, ], similarities: [ { a: 'pmid:100', b: 'pmid:101', similarity: 0.995 }, { a: 'pmid:100', b: 'pmid:103', similarity: 0.988 }, { a: 'pmid:100', b: 'pmid:102', similarity: 0.986 }, { a: 'pmid:200', b: 'pmid:202', similarity: 0.995 }, { a: 'pmid:200', b: 'pmid:201', similarity: 0.991 }, { a: 'pmid:200', b: 'pmid:203', similarity: 0.989 }, { a: 'pmid:300', b: 'pmid:303', similarity: 0.988 }, { a: 'pmid:300', b: 'pmid:302', similarity: 0.988 }, { a: 'pmid:300', b: 'pmid:301', similarity: 0.988 }, { a: 'pmid:101', b: 'pmid:102', similarity: 0.994 }, { a: 'pmid:101', b: 'pmid:103', similarity: 0.982 }, { a: 'pmid:201', b: 'pmid:202', similarity: 0.989 }, { a: 'pmid:201', b: 'pmid:203', similarity: 0.969 }, { a: 'pmid:301', b: 'pmid:302', similarity: 0.983 }, { a: 'pmid:301', b: 'pmid:303', similarity: 0.967 }, { a: 'pmid:102', b: 'pmid:103', similarity: 0.979 }, { a: 'pmid:202', b: 'pmid:203', similarity: 0.984 }, { a: 'pmid:302', b: 'pmid:303', similarity: 0.985 }, ], skipped: ["paper isbn:1: Unknown paper ID 'isbn:1': expected one of doi:…, pmid:…, pmcid:…, arxiv:…, openalex:…, s2:…"], } test('apply_clusters takes cluster_papers’ real result, and a second run with one more paper reuses its frames', async () => { const ctx = newCtx() addPapers(ctx, { papers: REAL.clusters.flatMap((c) => c.paperIds).map((id) => paper(id)) }) clusters = REAL as ClusterResult const result = await applyClusters(ctx, {}) assert.deepEqual( result.clusters, REAL.clusters.map(({ clusterId, paperIds, label }) => ({ clusterId, label: label ?? '', paperIds })) ) assert.deepEqual(result.skipped, []) assert.equal(frame(ctx, 'c-pmid_101').id, 'shape:cluster-c-pmid_101') assert.equal(frame(ctx, 'c-pmid_300').meta.labelGenerated, false) assertNoOverlaps(ctx) // the next call, with one more paper close to a cluster, keeps the cluster IDs addPapers(ctx, { papers: [paper('pmid:250')] }) clusters = { ...REAL, clusters: REAL.clusters.map((c) => (c.clusterId === 'c-pmid_200' ? { ...c, paperIds: [...c.paperIds, 'pmid:250'] } : c)), } as ClusterResult const frames = all(ctx).filter((r) => r.typeName === 'shape' && r.type === 'frame') const again = await applyClusters(ctx, {}) assert.deepEqual(again.clusters.map((c) => c.clusterId), ['c-pmid_101', 'c-pmid_200', 'c-pmid_300']) assert.deepEqual(all(ctx).filter((r) => r.typeName === 'shape' && r.type === 'frame').map((f) => f.id).sort(), frames.map((f) => f.id).sort()) assertNoOverlaps(ctx) }) test('a second apply_clusters puts the one more paper into its full cluster’s frame, widened when another frame is below', async () => { const ctx = newCtx() addPapers(ctx, { papers: REAL.clusters.flatMap((c) => c.paperIds).map((id) => paper(id)) }) clusters = REAL as ClusterResult await applyClusters(ctx, {}) const before = frame(ctx, 'c-pmid_200') addPapers(ctx, { papers: [paper('pmid:250')] }) clusters = { ...REAL, clusters: REAL.clusters.map((c) => (c.clusterId === 'c-pmid_200' ? { ...c, paperIds: [...c.paperIds, 'pmid:250'] } : c)), } as ClusterResult const again = await applyClusters(ctx, {}) assert.equal(card(ctx, 'pmid:250').parentId, before.id) assert.deepEqual(again.skipped, []) // one card column wider, the same height, in the same place const after = frame(ctx, 'c-pmid_200') assert.deepEqual([after.x, after.y, after.props.h], [before.x, before.y, before.props.h]) assert.equal(after.props.w, before.props.w + CARD_SIZE.w + 2 * MARGIN) assertNoOverlaps(ctx) }) test('a full cluster with shapes below it and to its right skips the paper', async () => { const ctx = newCtx() addPapers(ctx, { papers: REAL.clusters.flatMap((c) => c.paperIds).map((id) => paper(id)) }) clusters = REAL as ClusterResult await applyClusters(ctx, {}) const full = frame(ctx, 'c-pmid_200') // a person's frame just right of it, as tall as it putShapes(ctx.boardId, [ { id: 'shape:their-frame', type: 'frame', x: full.x + full.props.w + MARGIN, y: full.y, props: { w: 200, h: full.props.h, name: 'Mine', color: 'black' }, meta: { author: 'm_ben' }, }, ]) const before = all(ctx) addPapers(ctx, { papers: [paper('pmid:250')] }) clusters = { ...REAL, clusters: REAL.clusters.map((c) => (c.clusterId === 'c-pmid_200' ? { ...c, paperIds: [...c.paperIds, 'pmid:250'] } : c)), } as ClusterResult const again = await applyClusters(ctx, {}) assert.deepEqual(again.skipped, [{ paperId: 'pmid:250', reason: 'no room left in its cluster' }]) assert.notEqual(card(ctx, 'pmid:250').parentId, full.id) assert.deepEqual(frame(ctx, 'c-pmid_200'), full) assert.deepEqual( all(ctx).filter((r) => r.typeName === 'shape' && r.type === 'frame'), before.filter((r) => r.typeName === 'shape' && r.type === 'frame') ) }) test('apply_clusters writes nothing when the papers MCP fails, and needs two papers', async () => { const ctx = newCtx() addPapers(ctx, { papers: [paper('a'), paper('down')] }) const before = all(ctx) await rejects(() => applyClusters(ctx, {}), /unreachable.*nothing was written/) assert.deepEqual(all(ctx), before) const empty = newCtx() await rejects(() => applyClusters(empty, {}), /at least two papers/) })