Participants report taking the room into work (join)
deploy / deploy (push) Canceled after 0s

- 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:
2026-09-06 21:35:03 +03:00
parent 1fa7e733ba
commit bb0eb6979e
13 changed files with 146 additions and 12 deletions
+4
View File
@@ -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 });
}
+24 -1
View File
@@ -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}',
+13 -2
View File
@@ -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..."}');
+21 -3
View File
@@ -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 {
+6
View File
@@ -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 }[]) {
+2
View File
@@ -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 {
+21
View File
@@ -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);
+14
View File
@@ -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.',
+7
View File
@@ -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);
+10 -4
View File
@@ -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"}.
+8
View File
@@ -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 }),