From dd846848d9a007a8acd90abb2f5f6421fdc422c1 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Sun, 31 May 2026 16:15:43 +0300 Subject: [PATCH] fix: clarify mark-read target types --- docs/agent-quickstart.md | 6 ++ docs/api.md | 3 +- functions/api/[[path]].ts | 49 ++++++++++++-- scripts/agent-comms.mjs | 48 +++++++++++-- tests/api-auth.test.ts | 138 ++++++++++++++++++++++++++++++++++++++ tests/cli.test.ts | 25 +++++++ 6 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 tests/cli.test.ts diff --git a/docs/agent-quickstart.md b/docs/agent-quickstart.md index 729d747..aff4393 100644 --- a/docs/agent-quickstart.md +++ b/docs/agent-quickstart.md @@ -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 ` 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 diff --git a/docs/api.md b/docs/api.md index d66ae19..5395d74 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. | @@ -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"]' diff --git a/functions/api/[[path]].ts b/functions/api/[[path]].ts index 368380c..9690e4d 100644 --- a/functions/api/[[path]].ts +++ b/functions/api/[[path]].ts @@ -16,6 +16,7 @@ type Row = Record; 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; @@ -28,6 +29,32 @@ type AgentPair = { agentBId: string; }; +const markReadTargetTypes: MarkReadTargetType[] = ["thread", "conversation", "suggestion", "mention", "todo"]; +const markReadTargetAliases: Record = { + 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; } @@ -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; @@ -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", @@ -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 diff --git a/scripts/agent-comms.mjs b/scripts/agent-comms.mjs index 9a09cdd..7ff9844 100755 --- a/scripts/agent-comms.mjs +++ b/scripts/agent-comms.mjs @@ -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 @@ -46,6 +64,7 @@ Commands: live-receipt [agent-id] [note] [last-seen-message-id] live-receipt [note] [last-seen-message-id] mark-read [agent-id] + target-type: ${markReadTargetHelp} gates [status] gate <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] @@ -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) => @@ -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)}`); diff --git a/tests/api-auth.test.ts b/tests/api-auth.test.ts index eb16c40..ac6ac34 100644 --- a/tests/api-auth.test.ts +++ b/tests/api-auth.test.ts @@ -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", { @@ -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", diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..7df56d1 --- /dev/null +++ b/tests/cli.test.ts @@ -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"); + }); +});