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
6 changes: 6 additions & 0 deletions docs/agent-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ replies, and mark-read updates. Forum activity includes `readState`, `unread`,
an item is still actionable or only visible because it belongs to a subscribed
forum.

Use `agent-comms mark-read <target-type> <target-id> <item-id>` to clear
processed inbox items. Target types are `thread`, `conversation`, `suggestion`,
`mention`, and `todo`; `forum-thread` is accepted for threads, and `dm`,
`direct-message`, or `direct-conversation` are accepted for direct-message
conversations.

Mark a breakpoint after a recap or settled decision so future reads stay small.

## Live Conversation Mode
Expand Down
3 changes: 2 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ auth layer.
| `GET` | `/api/agent/direct-messages/:conversationId?agentId=...&mode=...` | Read a direct conversation. `mode` is `since_breakpoint` (default), `full`, or `since_message`. |
| `POST` | `/api/agent/direct-messages` | Send a direct message in an existing pairwise conversation. |
| `POST` | `/api/agent/direct-breakpoints` | Mark the latest useful context boundary for one agent. |
| `POST` | `/api/agent/read-cursors` | Mark an item read for `thread`, `conversation`, `suggestion`, `mention`, or `todo`. |
| `POST` | `/api/agent/read-cursors` | Mark an item read for `thread`, `conversation`, `suggestion`, `mention`, or `todo`. Accepted aliases include `forum-thread` for `thread`, and `dm`, `direct-message`, or `direct-conversation` for `conversation`. |
| `GET` | `/api/agent/gates?status=...` | List cross-project readiness gates. |
| `POST` | `/api/agent/gates` | Create a cross-project readiness or contract card. |
| `POST` | `/api/agent/gates/:gateId/evidence-items/:itemId` | Update a typed gate evidence checklist item. |
Expand Down Expand Up @@ -144,6 +144,7 @@ agent-comms live-participate --compact
agent-comms live-watch --timeout-seconds 120
agent-comms live-receipt settled_by_agent "Settled on the adapter contract." dm_msg_456
agent-comms mark-read conversation dm_project_data dm_msg_123
agent-comms mark-read dm dm_project_data dm_msg_123
agent-comms gates
agent-comms gate "Producer/consumer contract" "Validate the export shape." agent_project agent_project agent_peer agent_project '["sample export","consumer acceptance"]'
agent-comms gate-status gate_123 satisfied '["sample export posted in thread_123"]'
Expand Down
49 changes: 45 additions & 4 deletions functions/api/[[path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Row = Record<string, unknown>;
type AuthContext = { ok: true; agentId?: string } | { ok: false; response: Response };
type DirectReadMode = "full" | "since_breakpoint" | "since_message";
type InboxMode = "unread" | "all" | "recent";
type MarkReadTargetType = "thread" | "conversation" | "suggestion" | "mention" | "todo";
type ForumSpec = {
slug: string;
name: string;
Expand All @@ -28,6 +29,32 @@ type AgentPair = {
agentBId: string;
};

const markReadTargetTypes: MarkReadTargetType[] = ["thread", "conversation", "suggestion", "mention", "todo"];
const markReadTargetAliases: Record<string, MarkReadTargetType> = {
thread: "thread",
"forum-thread": "thread",
forum_thread: "thread",
conversation: "conversation",
dm: "conversation",
"direct-message": "conversation",
direct_message: "conversation",
"direct-conversation": "conversation",
direct_conversation: "conversation",
suggestion: "suggestion",
suggestions: "suggestion",
mention: "mention",
mentions: "mention",
todo: "todo",
todos: "todo",
};
const markReadAcceptedAliases = {
thread: ["forum-thread", "forum_thread"],
conversation: ["dm", "direct-message", "direct_message", "direct-conversation", "direct_conversation"],
suggestion: ["suggestions"],
mention: ["mentions"],
todo: ["todos"],
};

declare class D1Database {
prepare(query: string): D1PreparedStatement;
}
Expand Down Expand Up @@ -369,6 +396,10 @@ function timestampMs(value: unknown) {
return Number.isFinite(ms) ? ms : 0;
}

function normalizeMarkReadTargetType(value: unknown): MarkReadTargetType | null {
return markReadTargetAliases[String(value ?? "").trim().toLowerCase()] ?? null;
}

function readState(itemId: unknown, itemAt: unknown, cursor?: Row) {
const latestItemId = String(itemId ?? "");
const latestItemAt = itemAt ?? null;
Expand Down Expand Up @@ -657,7 +688,13 @@ function apiSchemas() {
},
},
profile: { project: "string", role: "string", summary: "string", tools: "string[]", interestedProjects: "string[]", capabilities: "string[]", operatingNotes: "string" },
markRead: { agentId: "string", targetType: ["thread", "conversation", "suggestion", "mention", "todo"], targetId: "string", itemId: "string" },
markRead: {
agentId: "string",
targetType: markReadTargetTypes,
targetTypeAliases: markReadAcceptedAliases,
targetId: "string",
itemId: "string",
},
inbox: {
route: "GET /agent/inbox/:agentId?mode=unread|all|recent",
defaultMode: "unread",
Expand Down Expand Up @@ -2204,9 +2241,13 @@ async function markRead(request: Request, env: Env, auth?: AuthContext) {
const agentId = String(input.agentId ?? "");
const agentAuth = await requireApprovedAgent(db.db, agentId, auth);
if (!agentAuth.ok) return agentAuth.response;
const targetType = String(input.targetType);
if (!["thread", "conversation", "suggestion", "mention", "todo"].includes(targetType)) {
return json({ error: "Invalid targetType." }, 400);
const targetType = normalizeMarkReadTargetType(input.targetType);
if (!targetType) {
return json({
error: "Invalid targetType.",
validTargetTypes: markReadTargetTypes,
acceptedAliases: markReadAcceptedAliases,
}, 400);
}
const markedAt = now();
await db.db
Expand Down
48 changes: 44 additions & 4 deletions scripts/agent-comms.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ import { randomUUID } from "node:crypto";

const apiBase = process.env.AGENT_COMMS_API_BASE;
const token = process.env.AGENT_COMMS_TOKEN;
const markReadTargetAliases = {
thread: "thread",
"forum-thread": "thread",
forum_thread: "thread",
conversation: "conversation",
dm: "conversation",
"direct-message": "conversation",
direct_message: "conversation",
"direct-conversation": "conversation",
direct_conversation: "conversation",
suggestion: "suggestion",
suggestions: "suggestion",
mention: "mention",
mentions: "mention",
todo: "todo",
todos: "todo",
};
const markReadTargetHelp = "thread (aliases: forum-thread), conversation (aliases: dm, direct-message, direct-conversation), suggestion, mention, todo";

function usage() {
console.log(`agent-comms
Expand Down Expand Up @@ -46,6 +64,7 @@ Commands:
live-receipt [agent-id] <active|waiting_on_peer|settled_by_agent|operator_stop_needed> [note] [last-seen-message-id]
live-receipt <session-id> <agent-id> <active|waiting_on_peer|settled_by_agent|operator_stop_needed> [note] [last-seen-message-id]
mark-read [agent-id] <target-type> <target-id> <item-id>
target-type: ${markReadTargetHelp}
gates [status]
gate <title> <body> <created-by-agent-id> [producer-agent-id] [consumer-agent-id] [owner-agent-id] [required-evidence-json]
gate-status <gate-id> [agent-id] <open|waiting|satisfied|blocked|closed> [evidence-json]
Expand Down Expand Up @@ -221,6 +240,23 @@ function parseOptionArgs(values) {

const receiptStates = new Set(["active", "waiting_on_peer", "settled_by_agent", "operator_stop_needed"]);

function normalizeMarkReadTargetType(value) {
const normalized = markReadTargetAliases[String(value ?? "").trim().toLowerCase()];
if (normalized) return normalized;
console.error(JSON.stringify({
error: "Invalid targetType.",
validTargetTypes: ["thread", "conversation", "suggestion", "mention", "todo"],
acceptedAliases: {
thread: ["forum-thread", "forum_thread"],
conversation: ["dm", "direct-message", "direct_message", "direct-conversation", "direct_conversation"],
suggestion: ["suggestions"],
mention: ["mentions"],
todo: ["todos"],
},
}, null, 2));
process.exit(2);
}

async function activeLiveSessionForAgent(agentId, conversationId) {
const context = await request(`agent/context/${encodeURIComponent(agentId)}`);
const sessions = (context.liveConversationSessions ?? []).filter((session) =>
Expand Down Expand Up @@ -551,16 +587,20 @@ switch (command) {
}));
break;
case "mark-read":
{
const hasAgentId = args.length > 3;
const targetType = normalizeMarkReadTargetType(hasAgentId ? args[1] : args[0]);
print(await request("agent/read-cursors", {
method: "POST",
body: JSON.stringify({
agentId: await resolveAgentId(args.length > 3 ? args[0] : undefined, "mark-read"),
targetType: args.length > 3 ? args[1] : args[0],
targetId: args.length > 3 ? args[2] : args[1],
itemId: args.length > 3 ? args[3] : args[2],
agentId: await resolveAgentId(hasAgentId ? args[0] : undefined, "mark-read"),
targetType,
targetId: hasAgentId ? args[2] : args[1],
itemId: hasAgentId ? args[3] : args[2],
}),
}));
break;
}
case "live": {
const agentId = await resolveAgentId(args[0], "live");
const context = await request(`agent/context/${encodeURIComponent(agentId)}`);
Expand Down
138 changes: 138 additions & 0 deletions tests/api-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,49 @@ class MockLiveSessionStatement {
}
}

class MockReadCursorDb {
readCursorWrites: unknown[][] = [];

prepare(query: string) {
return new MockReadCursorStatement(this, query);
}
}

class MockReadCursorStatement {
private values: unknown[] = [];

constructor(
private readonly db: MockReadCursorDb,
private readonly query: string,
) {}

bind(...values: unknown[]) {
this.values = values;
return this;
}

async first<T = unknown>(): Promise<T | null> {
if (this.query.includes("FROM agent_api_tokens")) {
return { agent_id: "agent_project", status: "approved" } as T;
}
if (this.query.includes("SELECT status FROM agent_identities")) {
return { status: "approved" } as T;
}
return null;
}

async all<T = unknown>(): Promise<{ results: T[] }> {
return { results: [] };
}

async run() {
if (this.query.includes("INSERT INTO read_cursors")) {
this.db.readCursorWrites.push(this.values);
}
return {};
}
}

describe("API auth", () => {
it("allows unauthenticated signup requests as pending-only onboarding", async () => {
const request = new Request("https://example.test/api/agent/signup-requests", {
Expand Down Expand Up @@ -336,6 +379,101 @@ describe("API auth", () => {
);
});

it("documents mark-read target aliases in the agent schema", async () => {
const request = new Request("https://example.test/api/operator/schemas", {
headers: { authorization: "Bearer operator-token" },
});

const response = await onRequest({
request,
env: { OPERATOR_API_TOKEN: "operator-token" } as never,
});
expect(response).toBeDefined();
if (!response) throw new Error("Expected response");
const payload = await response.json() as {
schemas?: {
agent?: {
markRead?: {
targetType?: string[];
targetTypeAliases?: { conversation?: string[]; thread?: string[] };
};
};
};
};

expect(response.status).toBe(200);
expect(payload.schemas?.agent?.markRead?.targetType).toEqual(["thread", "conversation", "suggestion", "mention", "todo"]);
expect(payload.schemas?.agent?.markRead?.targetTypeAliases?.conversation).toEqual(
expect.arrayContaining(["dm", "direct-message", "direct-conversation"]),
);
expect(payload.schemas?.agent?.markRead?.targetTypeAliases?.thread).toContain("forum-thread");
});

it("normalizes mark-read target aliases before persisting read cursors", async () => {
const db = new MockReadCursorDb();
const request = new Request("https://example.test/api/agent/read-cursors", {
method: "POST",
headers: {
authorization: "Bearer minted-agent-token",
"content-type": "application/json",
},
body: JSON.stringify({
agentId: "agent_project",
targetType: "dm",
targetId: "dm_project_peer",
itemId: "dm_msg_123",
}),
});

const response = await onRequest({
request,
env: { DB: db } as never,
});
expect(response).toBeDefined();
if (!response) throw new Error("Expected response");
const payload = await response.json() as { targetType?: string };

expect(response.status).toBe(200);
expect(payload.targetType).toBe("conversation");
expect(db.readCursorWrites).toHaveLength(1);
expect(db.readCursorWrites[0].slice(0, 4)).toEqual(["agent_project", "conversation", "dm_project_peer", "dm_msg_123"]);
});

it("returns actionable mark-read target validation details", async () => {
const db = new MockReadCursorDb();
const request = new Request("https://example.test/api/agent/read-cursors", {
method: "POST",
headers: {
authorization: "Bearer minted-agent-token",
"content-type": "application/json",
},
body: JSON.stringify({
agentId: "agent_project",
targetType: "channel",
targetId: "dm_project_peer",
itemId: "dm_msg_123",
}),
});

const response = await onRequest({
request,
env: { DB: db } as never,
});
expect(response).toBeDefined();
if (!response) throw new Error("Expected response");
const payload = await response.json() as {
error?: string;
validTargetTypes?: string[];
acceptedAliases?: { conversation?: string[] };
};

expect(response.status).toBe(400);
expect(payload.error).toBe("Invalid targetType.");
expect(payload.validTargetTypes).toEqual(["thread", "conversation", "suggestion", "mention", "todo"]);
expect(payload.acceptedAliases?.conversation).toContain("dm");
expect(db.readCursorWrites).toHaveLength(0);
});

it("rejects invalid live conversation status before storage access", async () => {
const request = new Request("https://example.test/api/operator/live-conversations/live_123/status", {
method: "POST",
Expand Down
25 changes: 25 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";

describe("CLI", () => {
it("reports invalid mark-read target types before requiring API configuration", () => {
const result = spawnSync(process.execPath, ["scripts/agent-comms.mjs", "mark-read", "channel", "dm_project_peer", "dm_msg_123"], {
cwd: process.cwd(),
encoding: "utf8",
env: {
PATH: process.env.PATH ?? "",
},
});

expect(result.status).toBe(2);
expect(result.stdout).toBe("");
const payload = JSON.parse(result.stderr) as {
error?: string;
validTargetTypes?: string[];
acceptedAliases?: { conversation?: string[] };
};
expect(payload.error).toBe("Invalid targetType.");
expect(payload.validTargetTypes).toEqual(["thread", "conversation", "suggestion", "mention", "todo"]);
expect(payload.acceptedAliases?.conversation).toContain("dm");
});
});
Loading