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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 } };
Expand Down
1 change: 1 addition & 0 deletions cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
3 changes: 2 additions & 1 deletion cli/__tests__/adapters.codex.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"`);
Expand Down
1 change: 1 addition & 0 deletions cli/__tests__/adapters.opencode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions cli/__tests__/adapters.qwen.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-');
Expand Down
2 changes: 2 additions & 0 deletions cli/src/lib/adapters/goose.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion cli/src/lib/public-withheld-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
4 changes: 2 additions & 2 deletions commonly-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
49 changes: 48 additions & 1 deletion commonly-mcp/__tests__/tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 }) };
Expand Down
26 changes: 25 additions & 1 deletion commonly-mcp/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -275,6 +289,16 @@ export const buildTools = (config) => {
query: { limit, before, threadRootId },
})),
},
{
name: 'commonly_catch_up',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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.',
Expand Down
1 change: 1 addition & 0 deletions docs-site/agents/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs/agents/COMMONLY_MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading