import type { GoalView, IndexedRecord, MaterializedIndex, MessageRecord } from '@radial/core' import { FIXTURE_DIDS } from '@radial/core/fixture' import { describe, expect, it } from 'vitest' import { buildFixtureSpace } from './fixture.js' import { answerAsks, answerState, askAnswerArgs, askedForAnswer, defaultResponder, messageArgs, pendingAnswers, pendingFor, possibleResponders, postedWithoutAsking, threadRows, wroteReply, } from './replies.js' import { retractable } from './requests.js' import { unitsOf } from './space.js' import { tally } from '@radial/core' // A reply is a message with `parent` set, and an answer request is a typed record naming one exact // message. Everything here is those two rules seen from the sides the UI touches: what the buttons // produce, how the thread reads once replies exist, and what a pending request hangs off. const space = buildFixtureSpace() const QUEUE = 'Review queue in the web UI' const BLOCKED = 'Capture requests should distill, not copy' const goal = (title: string): GoalView => { const found = space.index.goals.find((entry) => entry.target.value.title === title) if (!found) throw new Error(`fixture goal missing: ${title}`) return found } const messageBy = (view: GoalView, startsWith: string): IndexedRecord => { const found = view.messages.find((entry) => entry.value.body.startsWith(startsWith)) if (!found) throw new Error(`fixture message missing: ${startsWith}`) return found } describe('what a reply is', () => { it('is a message with --parent, and mentions whoever is being answered', () => { const view = goal(BLOCKED) const question = messageBy(view, 'The bundle gives me the source plan') expect( messageArgs({ goal: view.target.uri, body: 'Go with (a) — criteria only.', parent: question, author: FIXTURE_DIDS.ana, }), ).toEqual([ 'message', 'post', '--goal', view.target.uri, '--parent', `${question.uri}#${question.cid}`, '--mention', FIXTURE_DIDS.planner, '--body', 'Go with (a) — criteria only.', ]) }) it('does not mention the author replying to themselves', () => { const view = goal(BLOCKED) const question = messageBy(view, 'The bundle gives me the source plan') const args = messageArgs({ goal: view.target.uri, body: 'Never mind.', parent: question, author: FIXTURE_DIDS.planner, }) expect(args).not.toContain('--mention') expect(args).toContain('--parent') }) it('anchors a reply where its parent is, not where it is being read', () => { // A message anchored to an ARTIFACT under this goal is in this thread — the fold files it here — // and `message post` refuses a reply whose anchor disagrees with its parent's. Writing `--goal` // for every reply left those messages with a Reply button that could only produce a write error. const view = goal(QUEUE) const question = messageBy(view, 'Reads the index twice how?') const artifact = { uri: 'at://did:plc:agent/com.disnetdev.radial.artifact/plan-1', cid: 'cid-plan-1' } const { goal: _anchoredToTheGoal, ...rest } = question.value const onArtifact = { ...question, value: { ...rest, artifact } } expect( messageArgs({ goal: view.target.uri, body: 'Here.', parent: onArtifact, author: FIXTURE_DIDS.ana }), ).toEqual([ 'message', 'post', '--artifact', `${artifact.uri}#${artifact.cid}`, '--parent', `${onArtifact.uri}#${onArtifact.cid}`, '--mention', FIXTURE_DIDS.tim, '--body', 'Here.', ]) }) it('is an ordinary goal message when it answers nothing', () => { const view = goal(QUEUE) expect(messageArgs({ goal: view.target.uri, body: 'Starting on this.' })).toEqual([ 'message', 'post', '--goal', view.target.uri, '--body', 'Starting on this.', ]) }) it('says what a reply to an agent question unblocks, and stays quiet otherwise', () => { const planner = space.directory.get(FIXTURE_DIDS.planner) expect(wroteReply(planner, true)).toBe( 'Replied to planner.radial.bot — the request can be picked up again', ) expect(wroteReply(planner, false)).toBe('Replied to planner.radial.bot') }) }) describe('asking an agent to reply', () => { it('writes a typed request pinning the exact message version', () => { const view = goal(QUEUE) const question = messageBy(view, 'Reads the index twice how?') expect( askAnswerArgs({ goal: view.target.uri, message: { uri: question.uri, cid: question.cid }, assignee: FIXTURE_DIDS.reviewer, brief: ' Be specific about which read. ', basedOn: [{ uri: 'at://x/y/z', cid: 'cid-z' }], }), ).toEqual([ 'request', 'create', '--goal', view.target.uri, '--type', 'answer', '--subject', `${question.uri}#${question.cid}`, '--assignee', FIXTURE_DIDS.reviewer, '--based-on', 'at://x/y/z#cid-z', '--brief', 'Be specific about which read.', ]) }) it('refuses to write one with nobody named — v1 closes it on the assignee reply', () => { const view = goal(QUEUE) const question = messageBy(view, 'Reads the index twice how?') expect(() => askAnswerArgs({ goal: view.target.uri, message: { uri: question.uri, cid: question.cid }, assignee: '', brief: '', }), ).toThrow(/must name the agent/) }) it('offers only agents that publish `answer`, never a human and never the asker', () => { const responders = possibleResponders(space.directory, FIXTURE_DIDS.tim).map((actor) => actor.name) expect(responders).toEqual(['reviewer']) // `planner` publishes plans and `builder` implementations; neither answers. expect(responders).not.toContain('planner') expect(responders).not.toContain('builder') // A human is never on this list: asking a person to reply is what the thread already is. expect(responders).not.toContain('ana.dev') expect(possibleResponders(space.directory, FIXTURE_DIDS.reviewer)).toEqual([]) }) it('opens the picker on the message’s own author when that author answers', () => { const view = goal(QUEUE) const responders = possibleResponders(space.directory, FIXTURE_DIDS.tim) const byReviewer = messageBy(view, 'v2 resolves the strongref properly') expect(defaultResponder(byReviewer, responders)).toBe(FIXTURE_DIDS.reviewer) // A message by somebody who does not answer falls back to the first agent that does. const byHuman = messageBy(view, 'Starting from the review lexicon') expect(defaultResponder(byHuman, responders)).toBe(FIXTURE_DIDS.reviewer) expect(defaultResponder(byHuman, [])).toBe('') }) it('names who was asked, and says plainly when only the message landed', () => { expect(askedForAnswer(space.directory.get(FIXTURE_DIDS.reviewer))).toBe( 'Asked reviewer.radial.bot to reply', ) expect(askedForAnswer(undefined)).toBe('Asked an agent to reply') expect(postedWithoutAsking('the PDS said no')).toMatch( /Message posted, but the request was not written: the PDS said no/, ) }) }) describe('what the thread reads as', () => { it('nests a reply under what it answers, and keeps the row count to top-level messages', () => { const view = goal(QUEUE) const rows = threadRows(view) // Five messages: three standalone, and one exchange of two under the second. expect(view.messages).toHaveLength(5) expect(rows).toHaveLength(3) expect(rows[1]?.replies).toHaveLength(2) // The agent's answer replies to the human's question, not to the row it is drawn under, so it // says so rather than pretending the thread is deeper than one level. const [question, answer] = rows[1]?.replies ?? [] expect(question?.inReplyTo).toBeUndefined() expect(answer?.inReplyTo?.uri).toBe(question?.message.uri) expect(answer?.message.did).toBe(FIXTURE_DIDS.reviewer) }) it('shows a message whose parent lives outside this view as a row of its own', () => { const view = goal(QUEUE) const orphan = { ...messageBy(view, 'Starting from the review lexicon'), uri: 'at://did:plc:x/com.disnetdev.radial.message/orphan', value: { ...messageBy(view, 'Starting from the review lexicon').value, parent: { uri: 'at://elsewhere/msg/1', cid: 'cid-elsewhere' }, createdAt: '2026-07-21T23:00:00Z', }, } const rows = threadRows({ ...view, messages: [...view.messages, orphan] }) expect(rows.some((row) => row.message.uri === orphan.uri)).toBe(true) }) }) describe('a request somebody owes a reply on', () => { it('hangs off the exact message it pins, and is gone once the reply lands', () => { const view = goal(QUEUE) const answered = messageBy(view, 'Reads the index twice how?') const pending = messageBy(view, 'Separate thing: does the stale-view guard hold') // One row, under the message it pins rather than on the goal. The fixture's first exchange // completed — the reply threaded under its subject closed the request — so that message has // nothing pending on it at all, which is the whole of how a pending row disappears. const rows = pendingAnswers(view) expect(rows.size).toBe(1) expect(pendingFor(rows, answered)).toEqual([]) const [request] = pendingFor(rows, pending) if (!request) throw new Error('fixture answer request missing') expect(request.value.assignee).toBe(FIXTURE_DIDS.reviewer) expect(answerState(view, request)).toBe('assigned') // …and amber when the answer turn asked a question of its own instead. expect(answerState({ ...view, awaitingInput: [request.uri] }, request)).toBe('awaiting') }) it('offers Retract only to whoever the fold would honour a tombstone from', () => { const view = goal(QUEUE) const request = view.requests.find((entry) => entry.value.type === 'answer') if (!request) throw new Error('fixture answer request missing') expect(request.did).toBe(FIXTURE_DIDS.tim) // Its author, and an active admin (Tim is both here) — and nobody else. An answer request never // becomes a unit, so the thread asks this question directly instead of through // `retractableRequest`; the rule has to be the same one, or the row shows a control that reports // success and leaves the row exactly where it was. expect(retractable(request, space.directory, FIXTURE_DIDS.tim)).toBe(true) expect(space.directory.get(FIXTURE_DIDS.ana).role).toBe('member') expect(retractable(request, space.directory, FIXTURE_DIDS.ana)).toBe(false) expect(retractable(request, space.directory, FIXTURE_DIDS.builder)).toBe(false) expect(retractable(request, space.directory, '')).toBe(false) // A member who wrote it may withdraw it whatever their role. const byAna = { ...request, did: FIXTURE_DIDS.ana } expect(retractable(byAna, space.directory, FIXTURE_DIDS.ana)).toBe(true) }) it('is a row outside the thread too, carrying the message it pins and whoever it is with', () => { const view = goal(QUEUE) const question = messageBy(view, 'Separate thing: does the stale-view guard hold') // Every OPEN one, whatever state it is in — not just the parked ones. An ordinary open answer // request is with an agent, and the list that says so is the one it belongs in; the awaiting // lists take it from here only once its turn asks something back. const rows = answerAsks(space.index) expect(rows).toHaveLength(1) expect(rows[0]?.kind).toBe('answer') expect(rows[0]?.state).toBe('assigned') expect(rows[0]?.actor).toBe(FIXTURE_DIDS.reviewer) expect(rows[0]?.target.target.uri).toBe(view.target.uri) // It carries the message it pins, which is the whole of what a row outside the thread can say // about what was asked. expect(rows[0]?.subject?.uri).toBe(question.uri) // And the completed exchange is in no list at all: its request is closed, so it is not open. expect(rows.map((row) => row.request.rkey)).toEqual(['3lbq2ans0002']) }) it('is not dropped when it is a person who was asked', () => { // Nothing in the UI writes an answer request naming a human — asking a person to reply is what // the thread already is — but a record can say it, and such a request is parked on that person // when it blocks. Agent-ness is applied by the lists (`askWithAgent`), never inside this fold, // so a human-assigned ask cannot fall out of every list at once. const view = goal(QUEUE) const humanised: MaterializedIndex = { ...space.index, goals: space.index.goals.map((entry) => entry === view ? { ...entry, openRequests: entry.openRequests.map((request) => request.value.type === 'answer' ? { ...request, value: { ...request.value, assignee: FIXTURE_DIDS.ana } } : request, ), } : entry, ), } const rows = answerAsks(humanised) expect(rows).toHaveLength(1) expect(rows[0]?.state).toBe('assigned') expect(rows[0]?.actor).toBe(FIXTURE_DIDS.ana) }) it('is not a unit: it adds no row and does not move the goal’s tally', () => { const view = goal(QUEUE) const units = unitsOf(space.index, view) expect(units.some((unit) => unit.type === 'answer')).toBe(false) // Three rows: the plan, the implementation, and the custom-type request. The conversation under // them is not work, so the pie does not know about it. expect(units).toHaveLength(3) expect(tally(units)).toEqual({ total: 3, judged: 1 }) }) })