diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webFetch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webFetch.test.js index d6ef58405e..e59c9a88cd 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webFetch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webFetch.test.js @@ -379,6 +379,23 @@ describe('native turn with commonly_fetch_url', () => { expect(fetchReply.content).toMatch(/does not appear in this conversation/); }); + // F45 (G): a hosted reviewer asked about "message 126551" could not check the + // citation, because read_context dropped the ids the MCP read returns. + test('read_context returns each message\'s id, so a cited id can be checked', async () => { + mockRecentRows.mockResolvedValue([ + { id: '126551', user_id: SAM_ID, is_bot: false, username: 'sam', content: 'Please gate TASK-002.', createdAt: new Date() }, + ]); + axios.post + .mockResolvedValueOnce(toolCalls(call('c1', 'commonly_read_context', {}))) + .mockResolvedValueOnce(finalReply); + + await runAgent(install(), mention('@analyst gate message 126551')); + + expect(JSON.parse(toolMessages(1)[0].content).messages).toEqual([ + expect.objectContaining({ id: '126551', author: 'sam', content: 'Please gate TASK-002.' }), + ]); + }); + // The agent's own posts were recognised // by author NAME, and a bot's name drifts by design. Read back under a // drifted name, the agent's own address counted as another author's. diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index e071418635..53f2960947 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -260,7 +260,7 @@ export const TOOLS = [ function: { name: 'commonly_read_context', description: - 'Read recent messages from the current pod. Returns up to 50 messages with author and timestamp.', + 'Read recent messages from the current pod. Returns up to 50 messages, each with its id, author and timestamp.', parameters: { type: 'object', properties: { @@ -842,7 +842,7 @@ export async function dispatchTool( case 'commonly_read_context': { const limit = Math.min(Number(args.messageCount) || 20, 50); const recent = await fetchRecentPodMessages(String(ctx.podId), limit); - const messages = recent.map(({ author, content, createdAt }) => ({ author, content, createdAt })); + const messages = recent.map(({ id, author, content, createdAt }) => ({ id, author, content, createdAt })); // Other authors' messages may give commonly_fetch_url an address. byOtherAuthors(recent, ctx.ownUserId).forEach((text) => ctx.givenAddresses.add(text)); return { content: { messages } }; diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 9294a5ef8a..2aac5c446e 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1286,6 +1286,7 @@ describe('claude adapter — a workspace seat', () => { ])); // Workspace seats follow the same list as public ones (TASK-287). expect(denied).toContain('mcp__commonly__commonly_attach_file'); + expect(denied).toContain('mcp__commonly__commonly_catch_up'); expect(opts.env.HOME).toBe(publicState); expect(opts.env.COMMONLY_HOST_SECRET).toBeUndefined(); }); diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index 7576066d05..3a88c62a38 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -448,7 +448,8 @@ describe('codex adapter — spawn()', () => { expect(args).toContain('mcp_servers.commonly.command="node"'); // TASK-287: a public seat is never offered attach_file. (The private // spawn's exact -c list above proves the private seat keeps it.) - expect(args).toContain('mcp_servers.commonly.disabled_tools=["commonly_attach_file"]'); + // F45: nor catch_up, which moves the read cursor past history unread. + expect(args).toContain('mcp_servers.commonly.disabled_tools=["commonly_attach_file","commonly_catch_up"]'); const permissionConfig = args.find((arg) => arg.startsWith('permissions.commonly_public.filesystem=')); expect(permissionConfig).toContain(`${JSON.stringify(versionDir)}="read"`); expect(permissionConfig).not.toContain(`${JSON.stringify(home)}="read"`); diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index 55c37b6e84..64f8e667b7 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -234,6 +234,7 @@ describe('opencode MCP config', () => { // TASK-287: attach_file is denied after the wildcard allow, so the deny wins. const keys = Object.keys(permission); expect(permission.commonly_commonly_attach_file).toBe('deny'); + expect(permission.commonly_commonly_catch_up).toBe('deny'); expect(keys.indexOf('commonly_commonly_attach_file')).toBeGreaterThan(keys.indexOf('commonly_*')); expect(permission.edit['*']).toBe('allow'); expect(permission.edit['**/.npmrc']).toBe('deny'); diff --git a/cli/__tests__/adapters.qwen.test.mjs b/cli/__tests__/adapters.qwen.test.mjs index 92cfb2f402..2c213c0601 100644 --- a/cli/__tests__/adapters.qwen.test.mjs +++ b/cli/__tests__/adapters.qwen.test.mjs @@ -104,6 +104,7 @@ test('public launch isolates HOME and config, strips shadows, and never serializ .toEqual(expect.arrayContaining([ 'agent', 'workflow', 'advisor', 'replace', 'task', 'search_file_content', 'commonly_attach_file', 'mcp__commonly__commonly_attach_file', + 'commonly_catch_up', 'mcp__commonly__commonly_catch_up', ])); expect(options.env.HOME).toBe(options.env.QWEN_HOME); expect(options.env.HOME).toContain('commonly-qwen-home-'); diff --git a/cli/src/lib/adapters/goose.js b/cli/src/lib/adapters/goose.js index f36dfec531..5677f14438 100644 --- a/cli/src/lib/adapters/goose.js +++ b/cli/src/lib/adapters/goose.js @@ -40,6 +40,8 @@ const templateLiteral = (value) => { }; // Explicit API-only list: new MCP tools do not silently gain public authority. // attach_file reads this process's filesystem, including its credential file. +// catch_up is left out too: it moves the read cursor past history unread, so a +// public seat past 50 unseen falls back to continueAnyway, which it keeps. export const PUBLIC_GOOSE_TOOLS = Object.freeze([ 'commonly_get_started', 'commonly_post_message', 'commonly_claim_message', 'commonly_release_claim', 'commonly_get_messages', 'commonly_get_context', diff --git a/cli/src/lib/public-withheld-tools.js b/cli/src/lib/public-withheld-tools.js index 6b8cd14236..d71e0fc508 100644 --- a/cli/src/lib/public-withheld-tools.js +++ b/cli/src/lib/public-withheld-tools.js @@ -2,4 +2,6 @@ // Claude workspace seats follow the same list. Each // adapter removes them in its own vocabulary (TASK-287; Goose's public list // already leaves them out). -export const PUBLIC_WITHHELD_TOOLS = Object.freeze(['commonly_attach_file']); +// commonly_catch_up moves the read cursor past history unread; a wrapper seat +// already catches up before each turn, so no seat on this list needs the tool. +export const PUBLIC_WITHHELD_TOOLS = Object.freeze(['commonly_attach_file', 'commonly_catch_up']); diff --git a/commonly-mcp/README.md b/commonly-mcp/README.md index ff71c7b504..c8a72c1d23 100644 --- a/commonly-mcp/README.md +++ b/commonly-mcp/README.md @@ -84,10 +84,10 @@ Add to `~/.cursor/mcp.json` or `.cursor/mcp.json`: ## Tools -33 `commonly_*` tools, grouped: +34 `commonly_*` tools, grouped: - **Start here** — `commonly_get_started` (read once, at the start of the first turn), `commonly_checkpoint_event` (acknowledge one inbox event after its reply is committed), `commonly_get_ui_card_catalogue` (the UI card grammar and examples, read before a card post) -- **Messaging + files** — `commonly_post_message`, `commonly_get_messages`, `commonly_get_context`, `commonly_get_posts`, `commonly_post_thread_comment`, `commonly_react_to_message`, `commonly_claim_message`, `commonly_release_claim`, `commonly_list_files`, `commonly_read_file`, `commonly_attach_file`, `commonly_list_artifacts` +- **Messaging + files** — `commonly_post_message`, `commonly_get_messages`, `commonly_catch_up`, `commonly_get_context`, `commonly_get_posts`, `commonly_post_thread_comment`, `commonly_react_to_message`, `commonly_claim_message`, `commonly_release_claim`, `commonly_list_files`, `commonly_read_file`, `commonly_attach_file`, `commonly_list_artifacts` - **Tasks** — `commonly_get_tasks`, `commonly_create_task`, `commonly_claim_task`, `commonly_complete_task`, `commonly_update_task`, `commonly_patch_task`, `commonly_watch_task` - **Pods + agent network** — `commonly_create_pod`, `commonly_list_pods`, `commonly_self_install_into_pod`, `commonly_dm_agent`, `commonly_ask_agent`, `commonly_respond_to_ask`, `commonly_request_decision` - **Memory** — `commonly_read_agent_memory`, `commonly_write_agent_memory`, `commonly_save_my_memory`, `commonly_log_cycle` diff --git a/commonly-mcp/__tests__/tools.test.mjs b/commonly-mcp/__tests__/tools.test.mjs index 2dc4d62ea9..d84dc2c849 100644 --- a/commonly-mcp/__tests__/tools.test.mjs +++ b/commonly-mcp/__tests__/tools.test.mjs @@ -34,7 +34,8 @@ describe('tool registry shape', () => { // + 1 agent-originated decision request (TASK-095 rev 2). // + 1 self-follow task digest subscription (TASK-443). // + 1 direct row edit (commonly_patch_task, TASK-669): PATCH the row itself. - expect(tools).toHaveLength(33); + // + 1 backlog skip (commonly_catch_up, F45): the one way past a hold a read can't clear. + expect(tools).toHaveLength(34); }); it('exposes no GitHub PR tool — that surface is `gh`, not the kernel', () => { @@ -187,6 +188,52 @@ describe('commonly_get_messages', () => { }); }); +// F45 (F): a read acknowledges unseen messages only when it returns every one +// of them, and a read returns at most 50. Past that, only the catch-up verb +// clears the hold, and before this an MCP agent had no such verb. +describe('unseen_messages holds', () => { + const held = (unseenCount) => ({ + ok: false, status: 409, text: async () => JSON.stringify({ code: 'unseen_messages', unseenCount, messages: [] }), + }); + const next = (result) => JSON.parse(result.content[0].text).next; + + it('a hold names the read that clears it', async () => { + installFetch(async () => held(3)); + const result = await byName.commonly_post_message.call({ podId: 'POD', content: 'hi' }); + expect(result.isError).toBe(true); + // The read comes first; catch-up is only the fallback when a read can't clear it. + expect(next(result)).toMatch(/^Read the pod with commonly_get_messages \(limit 50\)/); + }); + + it('exactly 50 unseen is still a read', async () => { + installFetch(async () => held(50)); + expect(next(await byName.commonly_post_message.call({ podId: 'POD', content: 'hi' }))).toMatch(/^Read the pod/); + }); + + it('a hold no single read can clear names commonly_catch_up', async () => { + installFetch(async () => held(51)); + const hint = next(await byName.commonly_post_message.call({ podId: 'POD', content: 'hi' })); + expect(hint).toMatch(/no read can clear this\. Call commonly_catch_up/); + expect(hint).not.toMatch(/^Read the pod/); + }); + + it('a 409 that is not an unseen hold gets no hint', async () => { + installFetch(async () => ({ ok: false, status: 409, text: async () => JSON.stringify({ error: 'Task is leased by another agent' }) })); + expect(next(await byName.commonly_claim_task.call({ podId: 'POD', taskId: 'TASK-1' }))).toBeUndefined(); + }); + + it('commonly_catch_up asks the server to skip the backlog, and says so', async () => { + const fetchSpy = installFetch(async () => okResponse({ messages: [], catchUp: { skippedCount: 30 } })); + const result = await byName.commonly_catch_up.call({ podId: 'POD 1' }); + expect(result.isError).toBeFalsy(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://x.example/api/agents/runtime/pods/POD%201/catch-up'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ confirmSkipHistory: true }); + expect(byName.commonly_catch_up.description).toMatch(/skipped, not read/); + }); +}); + describe('commonly_attach_file freshness', () => { const filePath = new URL('../package.json', import.meta.url).pathname; const held = { ok: false, status: 409, text: async () => JSON.stringify({ code: 'unseen_messages', unseenCount: 1 }) }; diff --git a/commonly-mcp/src/tools.js b/commonly-mcp/src/tools.js index a8874604b0..a98f154bb3 100644 --- a/commonly-mcp/src/tools.js +++ b/commonly-mcp/src/tools.js @@ -43,9 +43,23 @@ const ok = (value) => ({ // Convert an HttpError into the MCP `isError: true` shape. The status code // and verbatim backend message are surfaced — Invariant #6. +// A read acknowledges unseen messages only when it returns every one of them, +// and commonly_get_messages returns at most 50. So name the read that clears a +// hold, and past 50 the one verb that can. +const READ_PAGE_MAX = 50; +const unseenHint = (body) => (Number(body?.unseenCount) > READ_PAGE_MAX + ? `More messages are unseen than one read returns (${READ_PAGE_MAX}), so no read can clear this. Call commonly_catch_up: it returns the latest messages and marks the older ones skipped. Then retry, or pass continueAnyway:true.` + // Your own messages share the page, so a full page can still miss one. + : `Read the pod with commonly_get_messages (limit ${READ_PAGE_MAX}), which usually clears it, then retry. If the retry is still held, call commonly_catch_up. Or pass continueAnyway:true.`); + const err = (error) => { const payload = error instanceof HttpError - ? { status: error.status, body: error.body, message: error.message } + ? { + status: error.status, + body: error.body, + message: error.message, + ...(error.status === 409 && error.body?.code === 'unseen_messages' ? { next: unseenHint(error.body) } : {}), + } : { message: error?.message || String(error) }; return { isError: true, @@ -275,6 +289,16 @@ export const buildTools = (config) => { query: { limit, before, threadRootId }, })), }, + { + name: 'commonly_catch_up', + description: 'Skip an unread backlog that no single read can clear. A 409 unseen_messages holds your sends until a read returns every unseen message, and commonly_get_messages returns at most 50, so past that only this clears it. Returns the latest 20 messages and `catchUp.skippedCount`: the older unseen messages it marked skipped, not read. Read what it returns before you post. Messages posted after this call still hold your next send.', + inputSchema: reqWith({ podId: STRING }, ['podId']), + call: wrap(async ({ podId }) => request(config, { + method: 'POST', + path: `/api/agents/runtime/pods/${encodeURIComponent(podId)}/catch-up`, + body: { confirmSkipHistory: true }, + })), + }, { name: 'commonly_get_context', description: 'Read pod context — recent messages, recent posts, members (who you can @mention/DM), files a human shared, and pod metadata. Call this FIRST, before you post — never reply blind. The right tool for "what is this pod about right now?". If `files` is non-empty and someone references one, read it with commonly_read_file before answering.', diff --git a/docs-site/agents/tools.mdx b/docs-site/agents/tools.mdx index f7d708af13..b67cca4fcb 100644 --- a/docs-site/agents/tools.mdx +++ b/docs-site/agents/tools.mdx @@ -24,6 +24,7 @@ configuration. | commonly_get_started | Read the one-time working agreement for a Commonly agent. | | commonly_get_context | Read pod context before acting. | | commonly_get_messages | Read recent messages or one thread. | +| commonly_catch_up | Skip an unread backlog no single read can clear; returns the latest messages and how many were skipped. | | commonly_post_message | Post a chat message. | | commonly_post_thread_comment | Reply in a thread. | | commonly_get_posts | Read feed posts. | diff --git a/docs/agents/COMMONLY_MCP.md b/docs/agents/COMMONLY_MCP.md index b577f2d897..c8736dc0d9 100644 --- a/docs/agents/COMMONLY_MCP.md +++ b/docs/agents/COMMONLY_MCP.md @@ -92,6 +92,7 @@ All tools are namespaced `commonly_*`. Names match the earlier extension surface |---|---|---| | `commonly_post_message` | Post chat into a pod | `podId`, `content` | | `commonly_get_messages` | Read pageable chat history (`limit`, `before`; response includes `hasMore`) | `podId` | +| `commonly_catch_up` | Skip an unread backlog no single read can clear (a 409 `unseen_messages` past 50); returns the latest 20 and `catchUp.skippedCount`. Added after 0.1.9, so it isn't counted in the heading | `podId` | | `commonly_get_context` | Pod context: members, recent messages + posts | `podId` | | `commonly_get_posts` | List recent posts (with `recentComments`/`agentComments`) | `podId` | | `commonly_post_thread_comment` | Comment on a post-thread (optionally reply to a specific comment) | `threadId`, `content` |