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
+12
View File
@@ -89,6 +89,18 @@ without re-reading the whole chat:
- **`what_you_should_do_next`** — explicit instruction computed from state - **`what_you_should_do_next`** — explicit instruction computed from state
- `available_actions` — the API calls you may make now - `available_actions` — the API calls you may make now
### Join (report the task is taken)
```
POST /api/rooms/:id/join
```
Marks the caller as having taken the room into work (idempotent; the first
timestamp is kept). The room creator is marked joined automatically at room
creation. Participant lists (`participants[].joined_at`), the agent Markdown
views, the observer page and the turn indicator all reflect join status: while
someone has not joined, the room is shown as waiting for them.
### Messages (append-only) ### Messages (append-only)
``` ```
+4 -2
View File
@@ -15,6 +15,7 @@ server (see "Transport vs Autonomy" in the README).
|---|---| |---|---|
| `rendezvous_create` | Create a room; returns one secret invite URL per participant | | `rendezvous_create` | Create a room; returns one secret invite URL per participant |
| `rendezvous_get` | Full room state incl. `what_you_should_do_next` | | `rendezvous_get` | Full room state incl. `what_you_should_do_next` |
| `rendezvous_join` | Report you have taken the room into work (first action after receiving the invite) |
| `rendezvous_post` | Append a message (verified facts, answers, arguments) | | `rendezvous_post` | Append a message (verified facts, answers, arguments) |
| `rendezvous_ask` | Open a question (optionally blocking, optionally addressed to a specific participant) | | `rendezvous_ask` | Open a question (optionally blocking, optionally addressed to a specific participant) |
| `rendezvous_resolve` | Resolve a question with the verified facts | | `rendezvous_resolve` | Resolve a question with the verified facts |
@@ -60,6 +61,7 @@ Or via npx from the repository root:
2. You call `rendezvous_create` with roles, `knows`, `needs_to_determine`. 2. You call `rendezvous_create` with roles, `knows`, `needs_to_determine`.
3. You keep `invite_urls[0]`'s token (that's you); give `invite_urls[1]` back 3. You keep `invite_urls[0]`'s token (that's you); give `invite_urls[1]` back
to the human to forward once. to the human to forward once.
4. Work in rounds: `rendezvous_get` → follow `what_you_should_do_next` 4. Work in rounds: `rendezvous_join` (once, first) → `rendezvous_get` → follow
`rendezvous_post` / `rendezvous_ask` / `rendezvous_resolve`. `what_you_should_do_next``rendezvous_post` / `rendezvous_ask` /
`rendezvous_resolve`.
5. `rendezvous_propose_contract`, then `rendezvous_finalize` when verified. 5. `rendezvous_propose_contract`, then `rendezvous_finalize` when verified.
+4
View File
@@ -42,6 +42,10 @@ export class RendezvousClient {
return this.request<RoomView>('GET', `/api/rooms/${roomId}`); 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> { postMessage(roomId: string, content: string): Promise<unknown> {
return this.request('POST', `/api/rooms/${roomId}/messages`, { content }); 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.'; 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( const addressedToYou = openQuestions.filter(
(q) => q.addressed_to === null || q.addressed_to === you.id, (q) => q.addressed_to === null || q.addressed_to === you.id,
); );
@@ -41,6 +54,7 @@ export function computeAdvice(input: AdviceInput): string {
if (blockingToYou.length > 0) { if (blockingToYou.length > 0) {
const first = blockingToYou[0]; const first = blockingToYou[0];
return ( return (
joinNote +
`There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` + `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}` : ''}). ` + `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.' '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) { if (blockingFromYou.length > 0) {
return ( return (
joinNote +
`You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` + `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.' '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( export function computeTurn(
status: string, 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 }[], openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[],
contract: { version: number; agreements: { participant_id: string; version: number }[] } | null, contract: { version: number; agreements: { participant_id: string; version: number }[] } | null,
): { text: string; waiting_for: string[] } { ): { 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: [] }; 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 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); const blocking = openQuestions.filter((q) => q.blocking);
if (blocking.length > 0) { if (blocking.length > 0) {
const q = blocking[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 ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
} }
return [ 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}/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 — open a question {question, blocking, addressed_to_participant_id?}',
'POST /api/rooms/{room_id}/questions/{question_id}/resolve — answer/resolve a question {resolution}', '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}`); parts.push(`**Whose turn:** ${o.turn}`);
if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`); if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`);
parts.push(''); 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('');
parts.push('## Open questions'); parts.push('## Open questions');
if (o.open_questions.length === 0) parts.push('(none)'); if (o.open_questions.length === 0) parts.push('(none)');
@@ -66,6 +71,8 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
parts.push(''); 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('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('');
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)'}`); parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
if (i.brief) parts.push(`- **Brief:** ${i.brief}`); if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
parts.push(`- **Status:** ${i.status}`); parts.push(`- **Status:** ${i.status}`);
@@ -74,7 +81,10 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
parts.push('## Participants'); parts.push('## Participants');
for (const p of i.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.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.needs_to_determine.length) parts.push(`- **needs to determine:** ${p.needs_to_determine.join('; ')}`);
if (p.instructions) parts.push(`- **instructions:** ${p.instructions}`); 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('## Available API actions');
parts.push(`Base URL: ${i.baseUrl}. Authenticate with \`Authorization: Bearer <your invite token>\` or \`?token=<token>\`.`); 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('- `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 + '/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": "...", "blocking": true, "addressed_to_participant_id": "..."}');
parts.push('- `POST /api/rooms/' + i.roomId + '/questions/{question_id}/resolve` — {"resolution": "verified facts..."}'); 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', ?, ?, ?)`, `INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`,
); );
const insertParticipant = this.db.prepare( const insertParticipant = this.db.prepare(
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at) `INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, joined_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
); );
const observerToken = newToken(); 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.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))), JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
(p.instructions ?? '').slice(0, 20000), (p.instructions ?? '').slice(0, 20000),
created.length === 0 ? createdAt : null, // creator is present from the start
createdAt, createdAt,
); );
created.push({ id: pid, role: p.role.trim(), token }); created.push({ id: pid, role: p.role.trim(), token });
@@ -166,7 +167,7 @@ export class RendezvousService {
listParticipants(roomId: string): ParticipantView[] { listParticipants(roomId: string): ParticipantView[] {
return (this.db 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)); .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[], knows: JSON.parse(p.knows) as string[],
needs_to_determine: JSON.parse(p.needs_to_determine) as string[], needs_to_determine: JSON.parse(p.needs_to_determine) as string[],
instructions: p.instructions, 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 ---------- // ---------- writes ----------
postMessage(roomId: string, token: string, content: string): MessageView { postMessage(roomId: string, token: string, content: string): MessageView {
+6
View File
@@ -24,6 +24,7 @@ export interface ParticipantRow {
knows: string; // JSON array knows: string; // JSON array
needs_to_determine: string; // JSON array needs_to_determine: string; // JSON array
instructions: string; instructions: string;
joined_at: string | null;
created_at: string; created_at: string;
} }
@@ -88,6 +89,7 @@ CREATE TABLE IF NOT EXISTS participants (
knows TEXT NOT NULL DEFAULT '[]', knows TEXT NOT NULL DEFAULT '[]',
needs_to_determine TEXT NOT NULL DEFAULT '[]', needs_to_determine TEXT NOT NULL DEFAULT '[]',
instructions TEXT NOT NULL DEFAULT '', instructions TEXT NOT NULL DEFAULT '',
joined_at TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -146,6 +148,10 @@ export class Store {
if (!cols.some((c) => c.name === 'observer_token')) { if (!cols.some((c) => c.name === 'observer_token')) {
this.db.exec('ALTER TABLE rooms ADD COLUMN observer_token TEXT'); 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. // Backfill: every room, including pre-observer-token ones, gets one.
const update = this.db.prepare('UPDATE rooms SET observer_token = ? WHERE id = ?'); 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 }[]) { 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[]; knows: string[];
needs_to_determine: string[]; needs_to_determine: string[];
instructions: string; instructions: string;
/** When this participant reported taking the room into work (null = invite not opened yet). */
joined_at: string | null;
} }
export interface MessageView { 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); 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', () => { test('only addressee or author can resolve a question', () => {
const { svc } = makeService(); const { svc } = makeService();
const created = createTwoPartyRoom(svc); 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( server.tool(
'rendezvous_post', 'rendezvous_post',
'Post a message to the room stating facts you verified on YOUR machine, answers, or arguments. Append-only: history cannot be edited.', '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) => { router.on('POST', '/api/rooms/:id/messages', (ctx) => {
writeAllowed(ctx); writeAllowed(ctx);
const token = requireToken(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. 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 /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. - \`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. - \`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 { export function observerHtmlPage(o: ObserverView, token: string): string {
const waiting = new Set(o.turn_waiting_for); const waiting = new Set(o.turn_waiting_for);
const roleList = o.participants const roleList = o.participants
.map((p) => .map((p) => {
waiting.has(p.role) const mark = waiting.has(p.role)
? `<span class="turn-role" title="the move is expected from this participant">► ${escapeHtml(p.role)}</span>` ? `<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(', '); .join(', ');
const msgs = o.conversation const msgs = o.conversation
.map( .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=) ## Endpoints (authenticate: Authorization: Bearer <your token> or ?token=)
- GET ${baseUrl}/create.md — full instructions for agents (start here). - 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}/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. - 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"}. - 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 // observer HTML highlights whose turn it is after a blocking question appears
const pidA = created.participants[0].id; const pidA = created.participants[0].id;
const tokB = created.invite_urls[1].split('/').pop(); 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`, { await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, {
method: 'POST', headers: { authorization: `Bearer ${tokB}`, 'content-type': 'application/json' }, 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 }), body: JSON.stringify({ question: 'verify X on your side', blocking: true, addressed_to_participant_id: pidA }),