Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,22 @@ When an agent's status changes (active / idle / busy / offline), all rooms it be
- Graceful shutdown
- Stale agent cleanup (the elected coordinator's PID probe)

## Durable room sends from the library

Library callers can opt a room message into the sender's local room-notice log:

```ts
import type { SendRoomMessageOptions } from "agent-comms";

const options: SendRoomMessageOptions = { durable: true };
await store.sendRoomMessage(roomId, senderId, "Keep this message", options);
```

`MeshStore` records a signed room-notice in the sender's own log only when `durable` is `true`.
This only writes the notice locally: nothing currently announces or fetches these room-notice
entries. Automatic offline catch-up still needs a catch-up policy and bridge wiring.
`FileStore` ignores the option because it already persists every message.

## Delivery status and read receipts

Messages carry a `readBy` field tracking which agents have consumed them. Status events are emitted to the sender automatically — no explicit action needed.
Expand Down
2 changes: 2 additions & 0 deletions src/core/comms-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
export interface SendRoomMessageOptions {
replyTo?: string | undefined;
streamingBehavior?: StreamingBehavior | undefined;
/** When true, `MeshStore` also records the message as a signed room-notice in the sender's own log for offline catch-up. `FileStore` ignores it, since it already persists every message. */
durable?: boolean | undefined;
}

/** What became of a room message for one specific member of that room. Delivery is per member because a room send fans out one directed request each, and a member that is unreachable right now is independent of every other. */
Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Bridges (pi extension, Claude Code channel) are in ../bridges/.
*/

export type { CommsStore } from "./comms-store.js";
export type { CommsStore, SendRoomMessageOptions } from "./comms-store.js";
export { FileStore, CommsError } from "./store.js";
export { MeshStore } from "./mesh-store.js";
export { CommsTool } from "./tool.js";
Expand Down
7 changes: 1 addition & 6 deletions src/core/room-messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,6 @@ export interface RoomMessagingDeps {
resolveAgent: (id: string) => Promise<AgentIdentity | undefined>;
}

/** RoomMessaging's own richer sendRoomMessage options, extending the public CommsStore-facing SendRoomMessageOptions with durable -- see sendRoomMessage's own doc comment for what durable means. */
export interface RoomMessagingSendOptions extends SendRoomMessageOptions {
durable?: boolean;
}

export class RoomMessaging {
constructor(private readonly deps: RoomMessagingDeps) {}

Expand All @@ -70,7 +65,7 @@ export class RoomMessaging {
roomIdOrName: string,
from: string,
content: string,
options?: RoomMessagingSendOptions,
options?: SendRoomMessageOptions,
): Promise<SentRoomMessage> {
const { replyTo, streamingBehavior, durable } = options ?? {};
const roomId = this.deps.resolveRoomId(roomIdOrName);
Expand Down
76 changes: 76 additions & 0 deletions src/test/mesh-store-durable-send.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { decode, cdeDecodeOptions } from "cbor2";
import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage";
import { createSystemClock } from "wire-mesh-core/adapters/system-clock";
import { readEntries } from "wire-mesh-core/domain/data-sync";
import { bytesFromHex } from "wire-mesh-core/domain/device-id";
import { createRevocationView } from "wire-mesh-core/domain/revocation-view";
import { verifyRoomNotice } from "wire-mesh-core/domain/room";
import type { RoomNotice } from "wire-mesh-core/generated/protocol";
import {
createBridgeMesh,
loadIdentityForFront,
releaseIdentityLock,
type CommsStore,
type SendRoomMessageOptions,
} from "../core/index.js";
import { oplogDirFor } from "../core/identity-store.js";
import { toIdentityPort } from "../core/wire-mesh-identity.js";

describe("public room-send durability", () => {
it.each([undefined, false, true])(
"records a notice only when durable is true (received %j)",
async (durable) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mesh-durable-"));
const slot = { harness: "durable-test", cwd: root, dataDir: root };
const mesh = await createBridgeMesh(slot, {
fetchLatestVersion: async () => undefined,
});
try {
const store: CommsStore = mesh.store;
const room = await store.createRoom({
name: "durable-room",
type: "public",
owner: mesh.store.peerId,
description: "",
});
const options: SendRoomMessageOptions = { durable };
const sent = await store.sendRoomMessage(
room.id,
mesh.store.peerId,
"public durable message",
options,
);
expect(sent.message.content).toBe("public durable message");

const entries = await readEntries(
createNodeFsStorage({ dir: oplogDirFor(slot) }),
Uint8Array.from(bytesFromHex(mesh.store.peerId)),
0,
);
expect(entries).toHaveLength(durable === true ? 1 : 0);
if (entries[0] !== undefined) {
const notice = decode<RoomNotice>(entries[0], cdeDecodeOptions);
const verdict = await verifyRoomNotice(notice, {
identity: await toIdentityPort(loadIdentityForFront(slot)),
clock: createSystemClock(),
revocation: createRevocationView(),
});
expect(verdict.ok).toBe(true);
if (!verdict.ok) return;
expect(verdict.claims.room).toBe(room.id);
expect(new TextDecoder().decode(verdict.claims.content)).toBe(
sent.message.content,
);
}
} finally {
await mesh.store.shutdown();
releaseIdentityLock(slot);
fs.rmSync(root, { recursive: true, force: true });
}
},
);
});
Loading