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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Prescriptive rules not derivable from reading the code. Full text, history and e
- **Agent reactions are first-class kernel primitives.** `POST /api/messages/:messageId/reactions` accepts human JWTs and agent runtime tokens via `dualAuth`; agents are gated by an active `AgentInstallation`, then `Pod.members`, and both paths fan out the same `messageReaction` socket event. Reactions live in Postgres (`message_reactions`). **Any new social-presence primitive (typing indicator, read receipt, …) MUST take the dual-auth shape** — never gate on `req.userId` alone, or agents are silently excluded.
- **codex MCP tools need an env table.** codex does not pass parent env to the MCP child it spawns, so `[mcp_servers.commonly]` must declare `env = { COMMONLY_API_URL, COMMONLY_AGENT_TOKEN }` or the server crashes at boot and no `commonly_*` tools surface. Check this first when tools stop surfacing.
- **Pod-scoped reads are membership-gated; admin moderation is a separate opt-in.** `getAllPods`, `getPodsByType` and `getPodById` filter to caller membership for everyone, admins included; `?scope=all` on `getAllPods` is the admin-only opt-in. `agent-room` and `agent-admin` 404 non-members; `agent-dm` allows the §3.7 read-only fan-out. **Every new pod-scoped read endpoint calls `DMService.canViewPod` before returning content.**
- **Agent displayName collisions are disambiguated in the DB, not at render time.** `scripts/dedupe-agent-display-names.ts` suffixes each non-canonical sibling deterministically; re-run it after bulk imports.
- **Agent displayName collisions are disambiguated in the DB, not at render time.** `backend/scripts/dedupe-agent-display-names.ts` suffixes each non-canonical sibling deterministically; re-run it after bulk imports.
- **DM display labels — never use `botMetadata.agentName`** (it holds the runtime, e.g. `openclaw`). Labels resolve via `agentIdentityService.resolveAgentDisplayLabel(user, fallback)`: `botMetadata.displayName` → `instanceId` (when not `default`) → `username` → fallback. Stale-data sweep: `backend/scripts/rename-agent-dm-pods.ts`.
- **Autonomous a2a DMs:** `commonly_open_dm({ agentName, instanceId? })` returns a podId, then `commonly_post_message(podId, content)` seeds the conversation. `/api/agents/runtime/agent-dm` enforces the §3.7 rule (caller and target must already share a pod). MCP agents reach the same capability as `commonly_dm_agent`.
- **A `commonly_*` tool name does NOT identify a capability.** The MCP server and the openclaw extension tool sets diverge, and one name can mean two things: `commonly_update_task` appends a note on MCP and PATCHes task fields (title included) on the extension. "There is no tool for X" describes one runtime's surface; check the route before concluding X is not permitted, and name the runtime you checked.
Expand Down
20 changes: 20 additions & 0 deletions backend/__tests__/unit/constants/firstPartyDisplayNames.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// isFirstPartyAgentName decides which agents take the default-first collision
// rule (TASK-551). It must match every curated agentName and nothing else.
const { isFirstPartyAgentName, FIRST_PARTY_DISPLAY_NAMES } = require('../../../constants/firstPartyDisplayNames');

describe('isFirstPartyAgentName', () => {
it('matches every curated first-party agentName, ignoring case and surrounding space', () => {
FIRST_PARTY_DISPLAY_NAMES.forEach((_label, agentName) => {
expect(isFirstPartyAgentName(agentName)).toBe(true);
expect(isFirstPartyAgentName(` ${agentName.toUpperCase()} `)).toBe(true);
});
});

it('does not match a community agentName, a label, or a blank one', () => {
expect(isFirstPartyAgentName('helper')).toBe(false);
expect(isFirstPartyAgentName('scout-demo')).toBe(false);
expect(isFirstPartyAgentName('Scout (Default)')).toBe(false);
expect(isFirstPartyAgentName('')).toBe(false);
expect(isFirstPartyAgentName(undefined)).toBe(false);
});
});
44 changes: 44 additions & 0 deletions backend/__tests__/unit/scripts/dedupeAgentDisplayNames.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// pickCanonical in backend/scripts/dedupe-agent-display-names (TASK-551). A
// group whose default instance belongs to a first-party agent ranks that
// instance first; every other group keeps the shortest-instanceId rule it has
// on main.
const { pickCanonical } = require('../../../scripts/dedupe-agent-display-names');

const claimant = (instanceId, agentName) => ({
_id: `${agentName}-${instanceId}`,
botMetadata: { displayName: 'x', instanceId, agentName },
});
const winner = (claims) => pickCanonical(
claims.map(([instanceId, agentName]) => claimant(instanceId, agentName)),
).botMetadata.instanceId;
const winnerId = (claims) => pickCanonical(
claims.map(([instanceId, agentName]) => claimant(instanceId, agentName)),
)._id;

describe('pickCanonical', () => {
it("ranks a first-party agent's default instance first, even against a shorter named peer", () => {
expect(winner([['scout', 'scout'], ['default', 'scout']])).toBe('default');
});

it('keeps the shortest instanceId for a community agent, so a shorter named peer beats its default', () => {
expect(winner([['default', 'helper'], ['bot', 'helper']])).toBe('bot');
});

it('does not let a community default outrank a shorter first-party peer on the same label', () => {
expect(winner([['default', 'helper'], ['lab', 'scout']])).toBe('lab');
});

it('breaks shortest-instanceId ties alphabetically', () => {
expect(winner([['pixel-b', 'pixel'], ['pixel-a', 'pixel']])).toBe('pixel-a');
});

it("ranks a first-party default above a community default on the same label, in either row order", () => {
expect(winnerId([['default', 'helper'], ['default', 'scout']])).toBe('scout-default');
expect(winnerId([['default', 'scout'], ['default', 'helper']])).toBe('scout-default');
});

it('breaks an instanceId tie by agentName, so row order never decides it', () => {
expect(winnerId([['lab', 'helper'], ['lab', 'bot']])).toBe('bot-lab');
expect(winnerId([['lab', 'bot'], ['lab', 'helper']])).toBe('bot-lab');
});
});
148 changes: 148 additions & 0 deletions backend/__tests__/unit/scripts/repairFirstPartyLabels.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// The planner behind scripts/repair-first-party-labels (TASK-551). Each repair
// rule is pinned on its own, the rows it must leave alone are pinned too, and
// a label another agent already holds is held rather than written.
const { planLabelRepairs, splitRepairs, mirrorLabel } = require('../../../scripts/repair-first-party-labels');

const row = (overrides) => ({
id: 'u1',
username: 'scout',
agentName: 'scout',
instanceId: 'default',
displayName: 'scout',
...overrides,
});

describe('planLabelRepairs', () => {
it('gives a first-party agent stored under its bare agentName its curated label', () => {
expect(planLabelRepairs([row({ displayName: 'scout' })])).toEqual([
{
id: 'u1', username: 'scout', from: 'scout', to: 'Scout', reason: 'first-party',
},
]);
});

it('fills a blank first-party label', () => {
expect(planLabelRepairs([row({ displayName: '' })])).toEqual([
expect.objectContaining({ from: '', to: 'Scout', reason: 'first-party' }),
]);
});

it('strips the (Default) suffix from a first-party default-instance row', () => {
expect(planLabelRepairs([row({ displayName: 'Scout (Default)' })])).toEqual([
{
id: 'u1', username: 'scout', from: 'Scout (Default)', to: 'Scout', reason: 'default-suffix',
},
]);
});

it('leaves a non-first-party default row alone', () => {
expect(planLabelRepairs([
row({ agentName: 'pixel', username: 'pixel', displayName: 'Pixel (Default)' }),
])).toEqual([]);
});

it('does not strip a (Default) suffix when the stripped label is not the curated label of its agent', () => {
expect(planLabelRepairs([
row({ displayName: 'My Scout (Default)' }),
])).toEqual([]);
});

it('leaves a (Default) label alone on an agent with no curated name', () => {
expect(planLabelRepairs([
row({ agentName: 'helper', username: 'helper', displayName: 'Scout (Default)' }),
])).toEqual([]);
});

it('leaves a named instance alone, even when its label ends in (Default)', () => {
expect(planLabelRepairs([
row({ instanceId: 'scout-demo', displayName: 'Scout (Default)' }),
])).toEqual([]);
});

it('does not strip a suffix that is the whole label', () => {
expect(planLabelRepairs([
row({ displayName: ' (Default)' }),
])).toEqual([]);
});

it('leaves a blank label alone for an agent without a curated name', () => {
expect(planLabelRepairs([
row({ agentName: 'pixel', username: 'pixel', displayName: '' }),
])).toEqual([]);
});

it('is idempotent: correct labels produce no repairs', () => {
expect(planLabelRepairs([
row({ displayName: 'Scout' }),
row({
id: 'u2', agentName: 'pixel', username: 'pixel', displayName: 'Pixel',
}),
])).toEqual([]);
});

it('holds a first-party default row whose label a named peer already holds', () => {
const takenBy = new Map([['Scout', ['scout']]]);
expect(planLabelRepairs([row({ displayName: 'Scout (Default)' })], takenBy)).toEqual([
expect.objectContaining({ to: 'Scout', heldBy: ['scout'] }),
]);
});

it('repairs a label that only a per-user row holds', () => {
const takenBy = new Map([['Scout', ['u0da521ab41']]]);
const [repair] = planLabelRepairs([row({ displayName: 'Scout (Default)' })], takenBy);
expect(repair.to).toBe('Scout');
expect(repair.heldBy).toBeUndefined();
});

it('per-user rows neither hold nor contest a label', () => {
const takenBy = new Map([['Scout', ['uabcdef0123']]]);
expect(planLabelRepairs([
row({
id: 'u1', username: 'scout-a', instanceId: 'u0da521ab41', displayName: 'scout',
}),
row({
id: 'u2', username: 'scout-b', instanceId: 'uabcdef0123', displayName: '',
}),
], takenBy)).toEqual([
{
id: 'u1', username: 'scout-a', from: 'scout', to: 'Scout', reason: 'first-party',
},
{
id: 'u2', username: 'scout-b', from: '', to: 'Scout', reason: 'first-party',
},
]);
});

it('holds both non-per-user repairs that would land on one label in the same run', () => {
expect(planLabelRepairs([
row({ id: 'u1', instanceId: 'default', displayName: 'Scout (Default)' }),
row({
id: 'u2', username: 'scout-named', instanceId: 'scout', displayName: '',
}),
])).toEqual([
expect.objectContaining({ id: 'u1', heldBy: ['scout'] }),
expect.objectContaining({ id: 'u2', heldBy: ['default'] }),
]);
});
});

describe('splitRepairs', () => {
it('skips a first-party default row whose target label a named peer holds, and never writes it', () => {
const repairs = planLabelRepairs([row({ displayName: 'Scout (Default)' })], new Map([['Scout', ['lab']]]));
const { writable, skipped } = splitRepairs(repairs);
expect(writable).toEqual([]);
expect(skipped).toEqual([expect.objectContaining({ id: 'u1', to: 'Scout', heldBy: ['lab'] })]);
});

it('still writes a per-user repair onto the label a skipped row would have taken', () => {
const repairs = planLabelRepairs([
row({ displayName: 'Scout (Default)' }),
row({
id: 'u2', username: 'scout-u0da521ab41', instanceId: 'u0da521ab41', displayName: 'scout',
}),
], new Map([['Scout', ['lab']]]));
const { writable, skipped } = splitRepairs(repairs);
expect(skipped.map((r) => r.id)).toEqual(['u1']);
expect(writable.map((r) => r.id)).toEqual(['u2']);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Tests for the inline displayName collision resolver in
// getOrCreateAgentUser. This is the sticky-dedup path (counterpart to the
// one-shot offline dedup script scripts/dedupe-agent-display-names.ts) —
// one-shot offline dedup script backend/scripts/dedupe-agent-display-names.ts) —
// every fresh install / reprovision now disambiguates inline, so a future
// reprovision-all run cannot reintroduce a "Pixel" / "Pixel" collision.
//
Expand Down Expand Up @@ -83,6 +83,8 @@ jest.mock('../../../models/User', () => {
});

const { default: AgentIdentityService } = require('../../../services/agentIdentityService');
const { FIRST_PARTY_DISPLAY_NAMES } = require('../../../constants/firstPartyDisplayNames');
const { FIRST_PARTY_APPS } = require('../../../config/native-agents/apps');

const reset = () => {
mockPeers = [];
Expand Down Expand Up @@ -504,6 +506,148 @@ describe('inline displayName collision resolver (sticky dedup)', () => {
expect(mockSaved[0].botMetadata.displayName).toBe('Pixel');
});

test('the default instance keeps its bare name when a shorter named peer shares it', async () => {
// 'scout' is shorter than 'default', so it used to outrank the official
// agent and turn its label into "Scout (Default)" in every invite line.
mockPeers = [
{
_id: 'named-scout',
botMetadata: { displayName: 'Scout', instanceId: 'scout' },
},
];
await AgentIdentityService.getOrCreateAgentUser('scout', { displayName: 'Scout' });
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Scout');
});

test('a named instance that collides with the default agent takes the suffix', async () => {
mockPeers = [
{
_id: 'default-scout',
botMetadata: { displayName: 'Scout', instanceId: 'default', agentName: 'scout' },
},
];
await AgentIdentityService.getOrCreateAgentUser('scout', {
instanceId: 'scout-demo',
displayName: 'Scout',
});
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Scout (Scout-Demo)');
});

test('a non-first-party default does not outrank a shorter named peer', async () => {
// Only a first-party agent's default takes the default-first rule. A
// community agent keeps the shortest-instanceId rule it has on main, so
// 'bot' keeps "Helper".
mockPeers = [
{ _id: 'default-helper', botMetadata: { displayName: 'Helper', instanceId: 'default', agentName: 'helper' } },
];
await AgentIdentityService.getOrCreateAgentUser('helper', {
instanceId: 'bot',
displayName: 'Helper',
});
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Helper');
});

test('a non-first-party default instance takes the suffix when a shorter named peer holds the label', async () => {
mockPeers = [
{ _id: 'bot-helper', botMetadata: { displayName: 'Helper', instanceId: 'bot', agentName: 'helper' } },
];
await AgentIdentityService.getOrCreateAgentUser('helper', {
instanceId: 'default',
displayName: 'Helper',
});
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Helper (Default)');
});

test('a first-party default keeps the bare name over a community default on the same label', async () => {
// Both claimants sit on 'default'. Only the first-party one keeps the bare
// name; the community agent's row takes the suffix.
mockPeers = [
{ _id: 'default-scout', botMetadata: { displayName: 'Scout', instanceId: 'default', agentName: 'scout' } },
];
await AgentIdentityService.getOrCreateAgentUser('helper', {
instanceId: 'default',
displayName: 'Scout',
});
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Scout (Default)');
});

test('two agents on one instanceId do not both keep the bare name', async () => {
mockPeers = [
{ _id: 'bot-lab', botMetadata: { displayName: 'Helper', instanceId: 'lab', agentName: 'bot' } },
];
await AgentIdentityService.getOrCreateAgentUser('helper', {
instanceId: 'lab',
displayName: 'Helper',
});
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Helper (Lab)');
});

test('a first-party agent with no displayName is stored under its curated label, not its agentName', async () => {
await AgentIdentityService.getOrCreateAgentUser('scout', {});
expect(mockSaved.length).toBe(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Scout');
});

test('upgrade of a legacy agent row stores the curated label, not the username (TASK-551)', async () => {
mockExisting = {
_id: 'legacy-scout-row',
username: 'scout',
email: 'scout@agents.commonly.local',
isBot: false,
};
await AgentIdentityService.getOrCreateAgentUser('scout', {});
expect(mockSaved).toHaveLength(1);
expect(mockSaved[0].isBot).toBe(true);
expect(mockSaved[0].botMetadata.displayName).toBe('Scout');
});

test('refresh of a first-party row with no stored label stores the curated label (TASK-551)', async () => {
mockExisting = {
_id: 'blank-scout-bot',
username: 'scout',
isBot: true,
botMetadata: {
agentName: 'scout',
instanceId: 'default',
displayName: '',
},
};
await AgentIdentityService.getOrCreateAgentUser('scout', {});
expect(mockSaved).toHaveLength(1);
expect(mockSaved[0].botMetadata.displayName).toBe('Scout');
});

test('refresh keeps a user-set label ("My Scout") over the displayName a routine post sends (TASK-551)', async () => {
// No runtime on the stored row, so the refresh arm runs. A routine post
// sends the curated "Scout"; the label the user set must survive it.
mockExisting = {
_id: 'my-scout-bot',
username: 'scout',
isBot: true,
botMetadata: {
agentName: 'scout',
instanceId: 'default',
displayName: 'My Scout',
},
};
await AgentIdentityService.getOrCreateAgentUser('scout', { displayName: 'Scout' });
expect(mockSaved).toHaveLength(1);
expect(mockSaved[0].botMetadata.displayName).toBe('My Scout');
});

test('the first-party label map agrees with every native first-party app config', () => {
expect(FIRST_PARTY_DISPLAY_NAMES.size).toBe(FIRST_PARTY_APPS.length);
FIRST_PARTY_APPS.forEach((app) => {
expect(FIRST_PARTY_DISPLAY_NAMES.get(app.agentName.toLowerCase())).toBe(app.displayName);
});
});

test('refresh (reprovision) on existing collision-suffixed name does NOT double-suffix', async () => {
mockExisting = {
_id: 'reprovision-id',
Expand Down
Loading
Loading