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
+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);