Experimental Bluesky client for agents
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594import { afterEach, describe, expect, test } from 'bun:test';import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { createReader, normalizeFeed } from '../src/atproto.ts';
const VIEWER_DID = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz';const AUTHOR_DID = 'did:plc:ragtjsm2j2vknwkz3zp4oxrd';const OTHER_DID = 'did:plc:js47frzswl2ezb6v235hlcz2';const REPOSTER_DID = 'did:plc:yckmmocpnqkrdwkp2q5rdipl';const CID = 'bafyreigh2akiscaildcw453k5p2q5w2dmqnj4dnrz3llrqmk5f3x7tdpiy';const POST_URI = `at://${AUTHOR_DID}/app.bsky.feed.post/primary`;const SECOND_URI = `at://${OTHER_DID}/app.bsky.feed.post/second`;const PARENT_URI = `at://${OTHER_DID}/app.bsky.feed.post/parent`;const ROOT_URI = `at://${OTHER_DID}/app.bsky.feed.post/root`;const QUOTE_URI = `at://${OTHER_DID}/app.bsky.feed.post/quote`;const SOURCE = { service: 'https://pds.example', mode: 'authenticated' as const, viewerDid: VIEWER_DID,};const originalFetch = globalThis.fetch;const temporaryDirectories: string[] = [];
type ApiObject = Record<string, any>;type FetchResponder = (request: Request) => Response | Promise<Response>;
function apiPost( uri: string, text: string, options: { authorDid?: string; record?: ApiObject; view?: ApiObject } = {},): ApiObject { const authorDid = options.authorDid ?? AUTHOR_DID; return { $type: 'app.bsky.feed.defs#postView', uri, cid: CID, author: { did: authorDid, handle: `${authorDid.slice('did:plc:'.length)}.test`, displayName: `Display ${authorDid.slice('did:plc:'.length)}`, }, record: { $type: 'app.bsky.feed.post', text, createdAt: '2026-09-05T10:00:00.000Z', ...options.record, }, indexedAt: '2026-09-05T10:00:01.000Z', ...options.view, };}
function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' }, });}
function installFetch(responder: FetchResponder): Request[] { const requests: Request[] = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const request = input instanceof Request && init === undefined ? input : new Request(input, init); requests.push(request.clone()); return responder(request); }) as typeof fetch; return requests;}
function temporaryAuthConfig(identifier: string, password: string): string { const directory = mkdtempSync(join(tmpdir(), 'perch-feed-auth-')); temporaryDirectories.push(directory); const path = join(directory, 'mcp.json'); writeFileSync(path, JSON.stringify({ mcpServers: { atproto: { env: { BSKY_IDENTIFIER: identifier, BSKY_APP_PASSWORD: password, }, }, }, }), { mode: 0o600 }); return path;}
afterEach(() => { globalThis.fetch = originalFetch; for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });});
describe('normalizeFeed', () => { test('preserves source-ordered occurrences separately from canonical posts and supplied context', () => { const parent = apiPost(PARENT_URI, 'Exact hydrated parent', { authorDid: OTHER_DID }); const root = apiPost(ROOT_URI, 'Exact hydrated root', { authorDid: OTHER_DID }); const primary = apiPost(POST_URI, 'Exact primary speech', { record: { facets: [{ index: { byteStart: 0, byteEnd: 5 }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: 'Exact' }], }], reply: { parent: { uri: PARENT_URI, cid: 'parent-cid' }, root: { uri: ROOT_URI, cid: 'root-cid' }, }, embed: { $type: 'app.bsky.embed.record', record: { uri: QUOTE_URI, cid: 'quote-cid' }, }, }, view: { replyCount: 87, likeCount: 3327, repostCount: 920, quoteCount: 4, embed: { $type: 'app.bsky.embed.images#view', images: [ null, { fullsize: 'https://cdn.example/full.jpg', thumb: 'https://cdn.example/thumb.jpg', alt: 'Exact image alt', aspectRatio: { width: 1200, height: 800 }, }, ], }, }, }); const second = apiPost(SECOND_URI, 'Second exact speech', { authorDid: OTHER_DID, view: { embed: { $type: 'app.bsky.embed.external#view', external: { uri: 'https://example.invalid/article', title: 'Exact card title', description: 'Exact card description', }, }, }, }); const response = { cursor: 'opaque-next-cursor', feed: [ { post: primary, reply: { parent, root }, reason: { $type: 'app.bsky.feed.defs#reasonRepost', by: { did: REPOSTER_DID, handle: 'reposter.test', displayName: 'Reposter' }, indexedAt: '2026-09-05T10:05:00.000Z', }, }, { post: primary, reason: { $type: 'com.example.feed#reasonSignal', by: { did: OTHER_DID, handle: 'other.test', displayName: 'Other' }, indexedAt: '2026-09-05T10:06:00.000Z', }, }, { post: second, reason: { $type: 'app.bsky.feed.defs#reasonPin' }, }, ], };
const snapshot = normalizeFeed(response, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, cursor: 'opaque-request-cursor', }); const nodes = new Map(snapshot.nodes.map((node) => [node.uri, node]));
expect(snapshot.entries.map(({ id, postUri }) => ({ id, postUri }))).toEqual([ { id: '1', postUri: POST_URI }, { id: '2', postUri: POST_URI }, { id: '3', postUri: SECOND_URI }, ]); expect(snapshot.entries[0]).toEqual({ id: '1', postUri: POST_URI, reason: { kind: 'repost', by: { did: REPOSTER_DID, handle: 'reposter.test', displayName: 'Reposter' }, indexedAt: '2026-09-05T10:05:00.000Z', }, parentUri: PARENT_URI, rootUri: ROOT_URI, }); expect(snapshot.entries[1].reason).toEqual({ kind: 'unknown', type: 'com.example.feed#reasonSignal', by: { did: OTHER_DID, handle: 'other.test', displayName: 'Other' }, indexedAt: '2026-09-05T10:06:00.000Z', }); expect(snapshot.entries[2].reason).toEqual({ kind: 'pin' }); expect(snapshot.nodes.filter((node) => node.uri === POST_URI)).toHaveLength(1); expect(nodes.get(POST_URI)).toMatchObject({ text: 'Exact primary speech', createdAt: '2026-09-05T10:00:00.000Z', indexedAt: '2026-09-05T10:00:01.000Z', parentUri: PARENT_URI, rootUri: ROOT_URI, counts: { replies: 87, likes: 3327, reposts: 920, quotes: 4 }, media: [{ kind: 'image', url: 'https://cdn.example/full.jpg', thumbnail: 'https://cdn.example/thumb.jpg', alt: 'Exact image alt', width: 1200, height: 800, }], quotes: [QUOTE_URI], }); expect(nodes.get(POST_URI)?.unsupported).toContain('app.bsky.embed.images#view:image-1-malformed'); expect(nodes.get(POST_URI)?.facets[0]?.targets[0]).toEqual({ kind: 'tag', value: 'Exact', label: 'Exact', ownerUri: POST_URI, index: 0, }); expect(nodes.get(PARENT_URI)?.text).toBe('Exact hydrated parent'); expect(nodes.get(PARENT_URI)?.replies).toContain(POST_URI); expect(nodes.get(ROOT_URI)?.text).toBe('Exact hydrated root'); expect(nodes.get(QUOTE_URI)).toMatchObject({ uri: QUOTE_URI, availability: 'unsupported', }); expect(nodes.get(QUOTE_URI)?.text).toBeUndefined(); expect(nodes.get(SECOND_URI)?.cards).toEqual([{ uri: 'https://example.invalid/article', title: 'Exact card title', description: 'Exact card description', }]); expect(nodes.get(SECOND_URI)?.counts.likes).toBeUndefined(); expect(snapshot).toMatchObject({ kind: 'feed', feed: 'following', fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, cursor: 'opaque-next-cursor', requestCursor: 'opaque-request-cursor', maxNodes: 600, locallyTruncated: false, warnings: [], }); });
test('reserves primary posts before applying the 600-node optional-context bound', () => { let nestedQuote: ApiObject | undefined; for (let index = 620; index >= 1; index -= 1) { const uri = `at://${OTHER_DID}/app.bsky.feed.post/quote-${index}`; nestedQuote = { $type: 'app.bsky.embed.record#viewRecord', uri, cid: `cid-quote-${index}`, author: { did: OTHER_DID, handle: 'other.test', displayName: 'Other' }, value: { $type: 'app.bsky.feed.post', text: `quote ${index}`, createdAt: '2026-09-05T09:00:00.000Z', }, embeds: nestedQuote ? [{ $type: 'app.bsky.embed.record#view', record: nestedQuote }] : [], }; } const primaryUris = [POST_URI, SECOND_URI, `at://${AUTHOR_DID}/app.bsky.feed.post/third`]; const feed = primaryUris.map((uri, index) => ({ post: apiPost(uri, `primary ${index + 1}`, index === 0 ? { view: { embed: { $type: 'app.bsky.embed.record#view', record: nestedQuote } } } : {}), }));
const snapshot = normalizeFeed({ feed }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 3, });
expect(snapshot.entries).toHaveLength(3); expect(snapshot.nodes).toHaveLength(600); expect(snapshot.nodes.slice(0, 3).map((node) => node.uri)).toEqual(primaryUris); expect(primaryUris.every((uri) => snapshot.nodes.some((node) => node.uri === uri))).toBe(true); expect(snapshot.locallyTruncated).toBe(true); expect(snapshot.warnings).toContain('Local feed maxNodes omitted one or more supplied context or quote nodes.'); });
test('rejects malformed primary identities while preserving an honest empty page and unusable cursor cue', () => { expect(() => normalizeFeed({ feed: [{}] }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, })).toThrow('Feed occurrence 1 omitted a usable canonical post URI'); expect(() => normalizeFeed({ feed: [{ post: apiPost('at://did:plc:test/app.bsky.actor.profile/not-a-post', 'bad') }] }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, })).toThrow('Feed occurrence 1 omitted a usable canonical post URI'); expect(() => normalizeFeed({ feed: [{ post: apiPost(`${POST_URI}/extra`, 'bad') }] }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, })).toThrow('Feed occurrence 1 omitted a usable canonical post URI'); for (const reservedKey of ['.', '..']) { expect(() => normalizeFeed({ feed: [{ post: apiPost(`at://${AUTHOR_DID}/app.bsky.feed.post/${reservedKey}`, 'bad'), }] }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, })).toThrow('Feed occurrence 1 omitted a usable canonical post URI'); } expect(() => normalizeFeed({ feed: [{ post: apiPost(`at://${AUTHOR_DID}/app.bsky.feed.post/${'x'.repeat(513)}`, 'bad'), }] }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, })).toThrow('Feed occurrence 1 omitted a usable canonical post URI'); const longestValidKey = 'A_~.:-0'.repeat(74).slice(0, 512); const longestValidUri = `at://${AUTHOR_DID}/app.bsky.feed.post/${longestValidKey}`; const longestValid = normalizeFeed({ feed: [{ post: apiPost(longestValidUri, 'valid boundary') }] }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, }); expect(longestValid.entries[0].postUri).toBe(longestValidUri);
const empty = normalizeFeed({ feed: [], cursor: '' }, { fetchedAt: '2026-09-05T10:10:00.000Z', source: SOURCE, limit: 20, }); expect(empty.entries).toEqual([]); expect(empty.nodes).toEqual([]); expect(empty.cursor).toBeUndefined(); expect(empty.warnings).toEqual(['Feed response supplied an empty cursor; continuation is unavailable.']); });});
describe('authenticated Reader.following', () => { test('does not create an authenticated reader from empty access or refresh credentials', async () => { const identifier = `fixture-${crypto.randomUUID()}.invalid`; const password = `fixture-${crypto.randomUUID()}`; const authConfig = temporaryAuthConfig(identifier, password); let loginCount = 0; const requests = installFetch((request) => { const url = new URL(request.url); if (!url.pathname.endsWith('/com.atproto.server.createSession')) { return jsonResponse({ error: 'TimelineMustNotRun' }, 500); } loginCount += 1; return jsonResponse({ did: VIEWER_DID, handle: 'viewer.test', accessJwt: loginCount === 1 ? '' : 'fixture-access', refreshJwt: loginCount === 1 ? 'fixture-refresh' : '', active: true, }); });
await expect(createReader({ authConfig, pds: SOURCE.service })) .rejects.toThrow('Authentication response omitted usable session credentials'); await expect(createReader({ authConfig, pds: SOURCE.service })) .rejects.toThrow('Authentication response omitted usable session credentials'); expect(requests).toHaveLength(2); expect(requests.every((request) => new URL(request.url).pathname.endsWith('/com.atproto.server.createSession'))).toBe(true); });
test('rejects public use, bounds options before timeline calls, retains source, and stops cursor loops', async () => { const identifier = `fixture-${crypto.randomUUID()}.invalid`; const passwordSuffix = `-${crypto.randomUUID()}`; const password = `invalid${passwordSuffix}`; const overlapProbe = `${identifier}${passwordSuffix}`; const accessSuffix = `-access-${crypto.randomUUID()}`; const refreshSuffix = `-refresh-${crypto.randomUUID()}`; const accessValue = `${password}${accessSuffix}`; const refreshValue = `${password}${refreshSuffix}`; const rotatedAccessSuffix = `-rotated-access-${crypto.randomUUID()}`; const rotatedRefreshSuffix = `-rotated-refresh-${crypto.randomUUID()}`; const rotatedAccessValue = `${password}${rotatedAccessSuffix}`; const rotatedRefreshValue = `${password}${rotatedRefreshSuffix}`; let rotationAttempts = 0; const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/com.atproto.server.createSession')) { return jsonResponse({ did: VIEWER_DID, handle: 'viewer.test', accessJwt: accessValue, refreshJwt: refreshValue, active: true, }); } if (url.pathname.endsWith('/com.atproto.server.refreshSession')) { return jsonResponse({ did: VIEWER_DID, handle: 'viewer.test', accessJwt: rotatedAccessValue, refreshJwt: rotatedRefreshValue, active: true, }); } if (url.pathname.endsWith('/com.atproto.identity.resolveHandle')) { return jsonResponse({ error: 'ResolverFailure', message: `echo ${overlapProbe} ${accessValue} ${refreshValue} ${rotatedAccessValue} ${rotatedRefreshValue}`, }, 503); } if (url.pathname.endsWith('/app.bsky.feed.getTimeline')) { const cursor = url.searchParams.get('cursor'); if (cursor === 'rotate-cursor') { rotationAttempts += 1; if (rotationAttempts === 1) { return jsonResponse({ error: 'ExpiredToken', message: 'synthetic expiry' }, 401); } return jsonResponse({ error: 'RetryFailure', message: `echo ${accessValue} ${refreshValue} ${rotatedAccessValue} ${rotatedRefreshValue}`, }, 503); } if (cursor === 'error-cursor') { return jsonResponse({ error: 'UpstreamFailure', message: `echo ${overlapProbe} ${accessValue} ${refreshValue} ${rotatedAccessValue} ${rotatedRefreshValue}`, }, 503); } return jsonResponse({ feed: [{ post: apiPost(POST_URI, 'Network fixture') }], cursor: cursor === 'loop-cursor' ? 'loop-cursor' : 'next-cursor', }); } return jsonResponse({ error: 'UnexpectedRequest', message: url.pathname }, 500); });
const publicReader = await createReader({ service: 'https://public.example' }); await expect(publicReader.following()).rejects.toThrow('Following feed requires an authenticated reader'); await expect(publicReader.thread(`${POST_URI}/extra`)).rejects.toThrow( 'Post AT URI must identify one app.bsky.feed.post record', ); expect(requests).toHaveLength(0);
const authConfig = temporaryAuthConfig(identifier, password); const reader = await createReader({ authConfig, pds: SOURCE.service }); expect(reader.source).toEqual(SOURCE); const page = await reader.following({ limit: 2, cursor: 'prior-cursor' }); const timelineRequest = requests.find((request) => new URL(request.url).pathname.endsWith('/app.bsky.feed.getTimeline')); expect(new URL(timelineRequest!.url).searchParams.get('limit')).toBe('2'); expect(new URL(timelineRequest!.url).searchParams.get('cursor')).toBe('prior-cursor'); expect(page.source).toEqual(SOURCE); expect(page.requestCursor).toBe('prior-cursor'); expect(page.cursor).toBe('next-cursor');
const requestCount = requests.length; await expect(reader.following({ limit: 0 })).rejects.toThrow('limit must be an integer from 1 through 100'); await expect(reader.following({ limit: 101 })).rejects.toThrow('limit must be an integer from 1 through 100'); await expect(reader.following({ cursor: '' })).rejects.toThrow('cursor must be a nonempty opaque string'); expect(requests).toHaveLength(requestCount); await expect(reader.following({ cursor: 'loop-cursor' })).rejects.toThrow('refusing a silent loop');
let rotationError: unknown; try { await reader.following({ cursor: 'rotate-cursor' }); } catch (error) { rotationError = error; } expect(rotationAttempts).toBe(2); expect(requests.some((request) => new URL(request.url).pathname.endsWith('/com.atproto.server.refreshSession'))).toBe(true); expect(String(rotationError)).toContain('Fetching following feed failed'); for (const secret of [ accessValue, refreshValue, rotatedAccessValue, rotatedRefreshValue, accessSuffix, refreshSuffix, rotatedAccessSuffix, rotatedRefreshSuffix, ]) { expect(String(rotationError)).not.toContain(secret); } expect(String(rotationError)).toContain('[redacted]');
let requestError: unknown; try { await reader.following({ cursor: 'error-cursor' }); } catch (error) { requestError = error; } expect(String(requestError)).toContain('Fetching following feed failed'); expect(String(requestError)).not.toContain(identifier); expect(String(requestError)).not.toContain(password); expect(String(requestError)).not.toContain(passwordSuffix); expect(String(requestError)).not.toContain(overlapProbe); expect(String(requestError)).not.toContain(accessValue); expect(String(requestError)).not.toContain(refreshValue); expect(String(requestError)).not.toContain(accessSuffix); expect(String(requestError)).not.toContain(refreshSuffix); expect(String(requestError)).not.toContain(rotatedAccessValue); expect(String(requestError)).not.toContain(rotatedRefreshValue); expect(String(requestError)).not.toContain(rotatedAccessSuffix); expect(String(requestError)).not.toContain(rotatedRefreshSuffix); expect(String(requestError)).toContain('[redacted]');
let resolverError: unknown; try { await reader.thread('https://bsky.app/profile/fail.test/post/focus'); } catch (error) { resolverError = error; } expect(String(resolverError)).toContain('Resolving handle fail.test failed'); for (const secret of [ identifier, password, passwordSuffix, overlapProbe, accessValue, refreshValue, accessSuffix, refreshSuffix, rotatedAccessValue, rotatedRefreshValue, rotatedAccessSuffix, rotatedRefreshSuffix, ]) { expect(String(resolverError)).not.toContain(secret); } expect(String(resolverError)).toContain('[redacted]'); });});
describe('Reader.authorFeed', () => { test('uses a canonical actor DID with public default source and preserves author pagination identity', async () => { const requests = installFetch((request) => { const url = new URL(request.url); if (url.pathname.endsWith('/app.bsky.actor.getProfile')) { return jsonResponse({ did: AUTHOR_DID, handle: 'author.test', displayName: 'Author', pronouns: 'they/them', }); } if (url.pathname.endsWith('/app.bsky.feed.getAuthorFeed')) { const cursor = url.searchParams.get('cursor'); return jsonResponse({ cursor: cursor === 'loop' ? 'loop' : cursor === 'page-one' ? 'page-two' : 'page-one', feed: [{ post: apiPost(POST_URI, 'Author feed post') }], }); } return jsonResponse({ error: 'UnexpectedRequest', message: url.pathname }, 500); }); const reader = await createReader({ service: 'https://public.example' }); const first = await reader.authorFeed('@AUTHOR.TEST', { limit: 3 }); expect(first).toMatchObject({ kind: 'feed', feed: 'author', source: { service: 'https://public.example', mode: 'public' }, actor: { did: AUTHOR_DID, handle: 'author.test', displayName: 'Author', pronouns: 'they/them' }, limit: 3, cursor: 'page-one', }); const next = await reader.authorFeed(first.actor!.did, { limit: 2, cursor: first.cursor }); expect(next.actor?.did).toBe(first.actor?.did); expect(next.requestCursor).toBe('page-one'); expect(next.cursor).toBe('page-two'); const authorRequests = requests .map((request) => new URL(request.url)) .filter(({ pathname }) => pathname.endsWith('/app.bsky.feed.getAuthorFeed')); expect(authorRequests.map((url) => ({ actor: url.searchParams.get('actor'), limit: url.searchParams.get('limit') }))).toEqual([ { actor: AUTHOR_DID, limit: '3' }, { actor: AUTHOR_DID, limit: '2' }, ]); await expect(reader.authorFeed(AUTHOR_DID, { cursor: 'loop' })).rejects.toThrow('refusing a silent loop'); const feedRequestsBeforeMismatch = requests.filter((request) => ( new URL(request.url).pathname.endsWith('/app.bsky.feed.getAuthorFeed') )).length; await expect(reader.authorFeed(OTHER_DID)).rejects.toThrow( `Profile changed canonical DID from ${OTHER_DID} to ${AUTHOR_DID}; refusing target substitution`, ); expect(requests.filter((request) => ( new URL(request.url).pathname.endsWith('/app.bsky.feed.getAuthorFeed') ))).toHaveLength(feedRequestsBeforeMismatch); });});