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
75 changes: 75 additions & 0 deletions backend/__tests__/service/agentsRuntime.room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,81 @@ describe('POST /api/agents/runtime/room — dual-auth (ADR-010 Phase 1)', () =>
expect(res.body.room.members).toHaveLength(2);
});

it('reopens the room after its reactive installation shares the agent identity', async () => {
const first = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', instanceId: 'default' });
expect(first.status).toBe(200);

// Creating the room adds a second active installation so Alice can
// reply there. That projection must not make a repeat "Talk to" call
// ambiguous with the original workspace installation.
const installations = await AgentInstallation.find({ agentName: 'alice', status: 'active' }).lean();
expect(installations).toHaveLength(2);

const second = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', instanceId: 'default' });
expect(second.status).toBe(200);
expect(String(second.body.room._id)).toBe(String(first.body.room._id));
expect(await Pod.countDocuments({ type: 'agent-room' })).toBe(1);
});

it('reopens the room when its reactive installation is the only one left', async () => {
const first = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', instanceId: 'default' });
expect(first.status).toBe(200);

// The room's reactive installation is the only surviving projection.
// It remains a valid legacy selection so reopening the existing 1:1
// never turns into a false "multiple installations" failure.
await AgentInstallation.deleteOne({
agentName: 'alice',
podId: pod._id,
status: 'active',
});
expect(await AgentInstallation.countDocuments({ agentName: 'alice', status: 'active' })).toBe(1);

const second = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', instanceId: 'default' });
expect(second.status).toBe(200);
expect(String(second.body.room._id)).toBe(String(first.body.room._id));
});

it('asks for podId when two workspace installations share an instance', async () => {
const first = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', instanceId: 'default' });
expect(first.status).toBe(200);

const secondWorkspacePod = await Pod.create({
name: 'Second Alice Workspace',
type: 'chat',
createdBy: humanUser._id,
members: [humanUser._id],
});
await request(app)
.post('/api/registry/install')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', podId: secondWorkspacePod._id.toString(), scopes: ['context:read'] });

const res = await request(app)
.post('/api/agents/runtime/room')
.set('Authorization', `Bearer ${humanToken}`)
.send({ agentName: 'alice', instanceId: 'default' });
expect(res.status).toBe(409);
expect(res.body.message).toBe('Multiple installations match that instanceId. Specify podId.');
// Refusal does not fork another personal room.
expect(await Pod.countDocuments({ type: 'agent-room' })).toBe(1);
});

it('returns 400 when agentName is missing', async () => {
const res = await request(app)
.post('/api/agents/runtime/room')
Expand Down
29 changes: 24 additions & 5 deletions backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,19 @@ router.post('/room', dualAuth, phase4RateLimit, async (req: any, res: any) => {
}).select('_id').lean();
const accessibleSet = new Set(accessiblePods.map((p: any) => p._id.toString()));

// Opening a room installs the agent into that room so it can reply. That
// reactive projection shares the agent's identity, but it is not a second
// workspace choice for this endpoint. Ignore personal-DM installations
// while selecting a workspace installation; if they are all that remain,
// retain the legacy fallback so an existing room stays reopenable.
const personalInstallPods = await Pod.find({
_id: { $in: candidatePodIds },
type: { $in: [...AgentIdentityService.DM_POD_TYPES_GUARD] },
}).select('_id').lean();
const personalInstallPodIds = new Set(
personalInstallPods.map((pod: any) => String(pod._id)),
);

const authorized = isAdmin
? installations
: installations.filter((i: any) => (
Expand All @@ -865,18 +878,24 @@ router.post('/room', dualAuth, phase4RateLimit, async (req: any, res: any) => {
...i,
instanceId: String(i.instanceId || 'default'),
}));
const workspaceInstalls = normalized.filter(
(installation: any) => !personalInstallPodIds.has(String(installation.podId)),
);
const selectableInstalls = workspaceInstalls.length ? workspaceInstalls : normalized;
let selected: any = null;
const byExact = normalizedInstanceId
? normalized.filter((i: any) => i.instanceId.toLowerCase() === normalizedInstanceId)
? selectableInstalls.filter((i: any) => i.instanceId.toLowerCase() === normalizedInstanceId)
: [];
if (byExact.length === 1) {
selected = byExact[0];
} else if (normalized.length === 1) {
selected = normalized[0];
} else if (selectableInstalls.length === 1) {
selected = selectableInstalls[0];
} else {
return res.status(409).json({
message: 'Multiple installations found. Specify instanceId.',
installations: normalized.map((i: any) => ({
message: normalizedInstanceId
? 'Multiple installations match that instanceId. Specify podId.'
: 'Multiple installations found. Specify instanceId (and podId if needed).',
installations: selectableInstalls.map((i: any) => ({
instanceId: i.instanceId,
podId: String(i.podId || ''),
})),
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,19 @@ describe('V2AgentProfile memory-write visibility', () => {
{ headers: { Authorization: 'Bearer viewer-token' } },
));
});

it('keeps room-endpoint diagnostics off the profile page', async () => {
window.localStorage.setItem('token', 'viewer-token');
profileClient.post.mockRejectedValue({
response: { data: { message: 'Multiple installations found. Specify instanceId.' } },
});
renderProfile();

fireEvent.click(await screen.findByRole('button', { name: 'Talk to Observer' }));

expect(await screen.findByRole('alert')).toHaveTextContent(
'The 1:1 with this agent could not be opened.',
);
expect(screen.queryByText('Multiple installations found. Specify instanceId.')).not.toBeInTheDocument();
});
});
12 changes: 5 additions & 7 deletions frontend/src/v2/agents/V2AgentProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,13 +233,11 @@ const V2AgentProfile: React.FC = () => {
const roomId = room.data?.room?._id;
if (!roomId) throw new Error('Agent room not returned');
navigate(`/v2/pods/${roomId}`);
} catch (err) {
const message = (err as { response?: { data?: { message?: string; error?: string; msg?: string } }; message?: string })
.response?.data?.message
|| (err as { response?: { data?: { error?: string; msg?: string } } }).response?.data?.error
|| (err as { response?: { data?: { msg?: string } } }).response?.data?.msg
|| t('agentProfile.hero.openRoomFailed');
setRoomError(message);
} catch {
// The profile is a human-facing entry point. Server diagnostics (for
// example, installation-selection detail) belong in observability, not
// as raw red copy on the profile page.
setRoomError(t('agentProfile.hero.openRoomFailed'));
setOpeningRoom(false);
}
}, [data, navigate, t]);
Expand Down
Loading