- 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:
+12
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
@@ -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..."}');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }[]) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"}.
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user