- POST /api/rooms/:id/join + rendezvous_join MCP tool; creator auto-joined - joined_at shown to agents, observer (invite not confirmed yet) and used by the turn indicator; what_you_should_do_next starts with joining - fix: participant list order is now stable (insertion order)
This commit is contained in:
@@ -42,6 +42,10 @@ export class RendezvousClient {
|
||||
return this.request<RoomView>('GET', `/api/rooms/${roomId}`);
|
||||
}
|
||||
|
||||
join(roomId: string): Promise<{ joined: boolean; participants: { role: string; joined_at: string | null }[] }> {
|
||||
return this.request('POST', `/api/rooms/${roomId}/join`, {});
|
||||
}
|
||||
|
||||
postMessage(roomId: string, content: string): Promise<unknown> {
|
||||
return this.request('POST', `/api/rooms/${roomId}/messages`, { content });
|
||||
}
|
||||
|
||||
@@ -30,6 +30,19 @@ export function computeAdvice(input: AdviceInput): string {
|
||||
return 'The contract is agreed by all participants and the room is finalized. Download the final artifact with GET /api/rooms/{room_id}/final.md (Authorization: Bearer <your token>) while the room still exists.';
|
||||
}
|
||||
|
||||
if (!you.joined_at) {
|
||||
return (
|
||||
'You have opened your invite but not reported taking the room into work yet. ' +
|
||||
'First POST /api/rooms/{room_id}/join (Authorization: Bearer <your token>) so the other side knows the task has been picked up, then read the room state again.'
|
||||
);
|
||||
}
|
||||
|
||||
const notJoined = input.participants.filter((p) => p.id !== you.id && !p.joined_at);
|
||||
const joinNote =
|
||||
notJoined.length > 0
|
||||
? `Note: ${notJoined.map((p) => p.role).join(', ')} has not joined yet (invite not confirmed) — you can state your facts now, but do not expect answers until they join. `
|
||||
: '';
|
||||
|
||||
const addressedToYou = openQuestions.filter(
|
||||
(q) => q.addressed_to === null || q.addressed_to === you.id,
|
||||
);
|
||||
@@ -41,6 +54,7 @@ export function computeAdvice(input: AdviceInput): string {
|
||||
if (blockingToYou.length > 0) {
|
||||
const first = blockingToYou[0];
|
||||
return (
|
||||
joinNote +
|
||||
`There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` +
|
||||
`First: "${first.question}" (asked by ${first.author_role}${first.addressed_to_role ? ` specifically of ${first.addressed_to_role}` : ''}). ` +
|
||||
'Verify the relevant facts on your side, post your answer as a message, then resolve the question with POST /api/rooms/{room_id}/questions/{question_id}/resolve including the verified facts in the resolution.'
|
||||
@@ -49,6 +63,7 @@ export function computeAdvice(input: AdviceInput): string {
|
||||
|
||||
if (blockingFromYou.length > 0) {
|
||||
return (
|
||||
joinNote +
|
||||
`You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` +
|
||||
'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved.'
|
||||
);
|
||||
@@ -99,7 +114,7 @@ export function computeAdvice(input: AdviceInput): string {
|
||||
*/
|
||||
export function computeTurn(
|
||||
status: string,
|
||||
participants: { id: string; role: string }[],
|
||||
participants: { id: string; role: string; joined_at: string | null }[],
|
||||
openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[],
|
||||
contract: { version: number; agreements: { participant_id: string; version: number }[] } | null,
|
||||
): { text: string; waiting_for: string[] } {
|
||||
@@ -108,6 +123,13 @@ export function computeTurn(
|
||||
return { text: 'Done: contract agreed by everyone. Fetch final.md before the room expires.', waiting_for: [] };
|
||||
|
||||
const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown';
|
||||
const notJoined = participants.filter((p) => !p.joined_at);
|
||||
if (notJoined.length > 0) {
|
||||
return {
|
||||
text: `Waiting for ${notJoined.map((p) => p.role).join(', ')} to open their invite and report they have taken the room into work.`,
|
||||
waiting_for: notJoined.map((p) => p.role),
|
||||
};
|
||||
}
|
||||
const blocking = openQuestions.filter((q) => q.blocking);
|
||||
if (blocking.length > 0) {
|
||||
const q = blocking[0];
|
||||
@@ -142,6 +164,7 @@ export function availableActions(status: string): string[] {
|
||||
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
|
||||
}
|
||||
return [
|
||||
'POST /api/rooms/{room_id}/join — report you have taken the room into work (once, first)',
|
||||
'POST /api/rooms/{room_id}/messages — post a message',
|
||||
'POST /api/rooms/{room_id}/questions — open a question {question, blocking, addressed_to_participant_id?}',
|
||||
'POST /api/rooms/{room_id}/questions/{question_id}/resolve — answer/resolve a question {resolution}',
|
||||
|
||||
@@ -11,7 +11,12 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string
|
||||
parts.push(`**Whose turn:** ${o.turn}`);
|
||||
if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`);
|
||||
parts.push('');
|
||||
parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`);
|
||||
parts.push(
|
||||
'Participants: ' +
|
||||
o.participants
|
||||
.map((p) => (p.joined_at ? `${p.role} (✅ joined ${p.joined_at})` : `${p.role} (⏳ invite not confirmed)`))
|
||||
.join(', '),
|
||||
);
|
||||
parts.push('');
|
||||
parts.push('## Open questions');
|
||||
if (o.open_questions.length === 0) parts.push('(none)');
|
||||
@@ -66,6 +71,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||
parts.push('');
|
||||
parts.push('You are a participant in an AI Rendezvous room — a temporary neutral meeting place for existing AI sessions on different machines, harnesses and providers. The server is transport-only state coordination; it never calls any model. Negotiate by exchanging messages and open questions, verify facts on your side, converge on the Agreed Contract. The room is deleted automatically.');
|
||||
parts.push('');
|
||||
parts.push('**First action:** if the Participants list below marks you as "has NOT reported" — POST /api/rooms/{room_id}/join (Authorization: Bearer your token) to announce you have taken the room into work. The other side sees it.');
|
||||
parts.push('');
|
||||
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
||||
if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
|
||||
parts.push(`- **Status:** ${i.status}`);
|
||||
@@ -74,7 +81,10 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||
|
||||
parts.push('## Participants');
|
||||
for (const p of i.participants) {
|
||||
parts.push(`### ${p.role}${p.display_name && p.display_name !== p.role ? ` (${p.display_name})` : ''}`);
|
||||
const joined = p.joined_at
|
||||
? `✅ joined ${p.joined_at}`
|
||||
: '⏳ has NOT reported taking this room into work yet';
|
||||
parts.push(`### ${p.role}${p.display_name && p.display_name !== p.role ? ` (${p.display_name})` : ''} — ${joined}`);
|
||||
if (p.knows.length) parts.push(`- **knows:** ${p.knows.join('; ')}`);
|
||||
if (p.needs_to_determine.length) parts.push(`- **needs to determine:** ${p.needs_to_determine.join('; ')}`);
|
||||
if (p.instructions) parts.push(`- **instructions:** ${p.instructions}`);
|
||||
@@ -120,6 +130,7 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||
parts.push('## Available API actions');
|
||||
parts.push(`Base URL: ${i.baseUrl}. Authenticate with \`Authorization: Bearer <your invite token>\` or \`?token=<token>\`.`);
|
||||
parts.push('- `GET /api/rooms/' + i.roomId + '` — full room state as JSON (your_role, room_goal, conversation, open_questions, current_contract, room_status, what_you_should_do_next)');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/join` — report that you have taken the room into work (do this first, once)');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/messages` — {"content": "..."}');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/questions` — {"question": "...", "blocking": true, "addressed_to_participant_id": "..."}');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/questions/{question_id}/resolve` — {"resolution": "verified facts..."}');
|
||||
|
||||
@@ -84,8 +84,8 @@ export class RendezvousService {
|
||||
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`,
|
||||
);
|
||||
const insertParticipant = this.db.prepare(
|
||||
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, joined_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
const observerToken = newToken();
|
||||
@@ -104,6 +104,7 @@ export class RendezvousService {
|
||||
JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
||||
JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
||||
(p.instructions ?? '').slice(0, 20000),
|
||||
created.length === 0 ? createdAt : null, // creator is present from the start
|
||||
createdAt,
|
||||
);
|
||||
created.push({ id: pid, role: p.role.trim(), token });
|
||||
@@ -166,7 +167,7 @@ export class RendezvousService {
|
||||
|
||||
listParticipants(roomId: string): ParticipantView[] {
|
||||
return (this.db
|
||||
.prepare('SELECT * FROM participants WHERE room_id = ? ORDER BY created_at, id')
|
||||
.prepare('SELECT rowid AS _rowid, * FROM participants WHERE room_id = ? ORDER BY rowid')
|
||||
.all(roomId) as unknown as ParticipantRow[]).map((p) => this.toParticipantView(p));
|
||||
}
|
||||
|
||||
@@ -178,6 +179,7 @@ export class RendezvousService {
|
||||
knows: JSON.parse(p.knows) as string[],
|
||||
needs_to_determine: JSON.parse(p.needs_to_determine) as string[],
|
||||
instructions: p.instructions,
|
||||
joined_at: p.joined_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,6 +342,22 @@ export class RendezvousService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that this participant has taken the room into work. Idempotent:
|
||||
* the first timestamp is kept. Lets the other side (and the human) see the
|
||||
* task has been picked up.
|
||||
*/
|
||||
join(roomId: string, token: string): { participants: ParticipantView[] } {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
this.ensureOpen(room);
|
||||
if (!participant.joined_at) {
|
||||
this.db
|
||||
.prepare('UPDATE participants SET joined_at = ? WHERE id = ? AND joined_at IS NULL')
|
||||
.run(nowIso(), participant.id);
|
||||
}
|
||||
return { participants: this.listParticipants(roomId) };
|
||||
}
|
||||
|
||||
// ---------- writes ----------
|
||||
|
||||
postMessage(roomId: string, token: string, content: string): MessageView {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ParticipantRow {
|
||||
knows: string; // JSON array
|
||||
needs_to_determine: string; // JSON array
|
||||
instructions: string;
|
||||
joined_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ CREATE TABLE IF NOT EXISTS participants (
|
||||
knows TEXT NOT NULL DEFAULT '[]',
|
||||
needs_to_determine TEXT NOT NULL DEFAULT '[]',
|
||||
instructions TEXT NOT NULL DEFAULT '',
|
||||
joined_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -146,6 +148,10 @@ export class Store {
|
||||
if (!cols.some((c) => c.name === 'observer_token')) {
|
||||
this.db.exec('ALTER TABLE rooms ADD COLUMN observer_token TEXT');
|
||||
}
|
||||
const pcols = this.db.prepare('PRAGMA table_info(participants)').all() as { name: string }[];
|
||||
if (!pcols.some((c) => c.name === 'joined_at')) {
|
||||
this.db.exec('ALTER TABLE participants ADD COLUMN joined_at TEXT');
|
||||
}
|
||||
// Backfill: every room, including pre-observer-token ones, gets one.
|
||||
const update = this.db.prepare('UPDATE rooms SET observer_token = ? WHERE id = ?');
|
||||
for (const r of this.db.prepare('SELECT id FROM rooms WHERE observer_token IS NULL').all() as { id: string }[]) {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ParticipantView {
|
||||
knows: string[];
|
||||
needs_to_determine: string[];
|
||||
instructions: string;
|
||||
/** When this participant reported taking the room into work (null = invite not opened yet). */
|
||||
joined_at: string | null;
|
||||
}
|
||||
|
||||
export interface MessageView {
|
||||
|
||||
@@ -165,6 +165,27 @@ test('destroyRoom: allowed for participant and observer, rejected for strangers'
|
||||
assert.throws(() => svc.getRoomView(c2.room_id, c2.participants[0].token), RendezvousError);
|
||||
});
|
||||
|
||||
test('join: creator auto-joined, invitee reports once and idempotently', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
const [, tokenB] = created.participants.map((p) => p.token);
|
||||
|
||||
const viewA = svc.getRoomView(created.room_id, created.participants[0].token);
|
||||
assert.ok(viewA.participants[0].joined_at, 'creator is joined from the start');
|
||||
assert.equal(viewA.participants[1].joined_at, null);
|
||||
|
||||
// B's advice tells it to join first
|
||||
const viewB = svc.getRoomView(created.room_id, tokenB);
|
||||
assert.match(viewB.what_you_should_do_next, /join/);
|
||||
|
||||
const first = svc.join(created.room_id, tokenB).participants.find((p) => p.role === 'application')!;
|
||||
const ts = first.joined_at!;
|
||||
assert.ok(ts);
|
||||
// idempotent: timestamp does not change
|
||||
const again = svc.join(created.room_id, tokenB).participants.find((p) => p.role === 'application')!;
|
||||
assert.equal(again.joined_at, ts);
|
||||
});
|
||||
|
||||
test('only addressee or author can resolve a question', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
|
||||
@@ -99,6 +99,20 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'rendezvous_join',
|
||||
'Report that YOU have taken this room into work. Call this ONCE right after receiving your invite URL / at the start of your participation, before other actions. It marks you as joined (with a timestamp) so the other side and the human observer know the task has been picked up. Idempotent.',
|
||||
{
|
||||
token: z.string().describe('Your secret participant token'),
|
||||
room_id: z.string().describe('Room id'),
|
||||
},
|
||||
async ({ token, room_id }) => {
|
||||
const r = await client(token).join(room_id);
|
||||
const status = r.participants.map((p) => `${p.role}: ${p.joined_at ? `joined ${p.joined_at}` : 'not joined yet'}`).join('; ');
|
||||
return { content: [{ type: 'text' as const, text: `You are marked as joined. Participants: ${status}` }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'rendezvous_post',
|
||||
'Post a message to the room stating facts you verified on YOUR machine, answers, or arguments. Append-only: history cannot be edited.',
|
||||
|
||||
@@ -184,6 +184,13 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
router.on('POST', '/api/rooms/:id/join', (ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
const r = service.join(ctx.params.id, token);
|
||||
sendJson(ctx.res, 200, { joined: true, ...r });
|
||||
});
|
||||
|
||||
router.on('POST', '/api/rooms/:id/messages', (ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
|
||||
@@ -65,6 +65,7 @@ You receive one **secret invite URL per participant** plus one **observer URL**
|
||||
|
||||
Authenticate every request with \`Authorization: Bearer <your token>\` (or \`?token=\`). Your token is in your invite URL.
|
||||
|
||||
- \`POST /api/rooms/<room_id>/join\` — **first action**: report you have taken the room into work; the other side (and the human) sees you joined.
|
||||
- \`GET /r/<room>/<your-token>.md\` — compact Markdown state: who you are, goal, messages, open questions, contract, available actions.
|
||||
- \`GET /api/rooms/<room_id>\` — full JSON state, including \`what_you_should_do_next\` — follow it; it tells you exactly what is expected of you now.
|
||||
- \`POST /api/rooms/<room_id>/messages\` \`{"content": "..."}\` — state facts from your side.
|
||||
@@ -180,11 +181,15 @@ export function destroyedPage(): string {
|
||||
export function observerHtmlPage(o: ObserverView, token: string): string {
|
||||
const waiting = new Set(o.turn_waiting_for);
|
||||
const roleList = o.participants
|
||||
.map((p) =>
|
||||
waiting.has(p.role)
|
||||
.map((p) => {
|
||||
const mark = waiting.has(p.role)
|
||||
? `<span class="turn-role" title="the move is expected from this participant">► ${escapeHtml(p.role)}</span>`
|
||||
: escapeHtml(p.role),
|
||||
)
|
||||
: escapeHtml(p.role);
|
||||
const joined = p.joined_at
|
||||
? `<span class="meta">✅ took the room into work ${escapeHtml(p.joined_at)}</span>`
|
||||
: '<span class="meta">⏳ invite not confirmed yet</span>';
|
||||
return `${mark} (${joined})`;
|
||||
})
|
||||
.join(', ');
|
||||
const msgs = o.conversation
|
||||
.map(
|
||||
@@ -303,6 +308,7 @@ The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the n
|
||||
## Endpoints (authenticate: Authorization: Bearer <your token> or ?token=)
|
||||
|
||||
- GET ${baseUrl}/create.md — full instructions for agents (start here).
|
||||
- POST ${baseUrl}/api/rooms/<room_id>/join — FIRST ACTION after opening your invite: report you have taken the room into work (idempotent). The other side and the observer see your joined status.
|
||||
- GET ${baseUrl}/r/<room>/<your-token>.md — compact Markdown room state: who you are, goal, messages, open questions, contract, available actions.
|
||||
- GET ${baseUrl}/api/rooms/<room_id> — full JSON state incl. what_you_should_do_next and available_actions.
|
||||
- POST ${baseUrl}/api/rooms/<room_id>/messages — {"content":"verified facts / answers"}.
|
||||
|
||||
@@ -119,6 +119,14 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page
|
||||
// observer HTML highlights whose turn it is after a blocking question appears
|
||||
const pidA = created.participants[0].id;
|
||||
const tokB = created.invite_urls[1].split('/').pop();
|
||||
// before join: observer turn waits on B joining
|
||||
const obsHtml0 = await (await fetch(created.observer_url)).text();
|
||||
assert.match(obsHtml0, /invite not confirmed/);
|
||||
// B reports taking the room into work
|
||||
const j = await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/join`, {
|
||||
method: 'POST', headers: { authorization: `Bearer ${tokB}` },
|
||||
})).json();
|
||||
assert.equal((j as any).joined, true);
|
||||
await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, {
|
||||
method: 'POST', headers: { authorization: `Bearer ${tokB}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ question: 'verify X on your side', blocking: true, addressed_to_participant_id: pidA }),
|
||||
|
||||
Reference in New Issue
Block a user