From 4544b2666ab69ff5a3724ef361a3449c538b3293 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:07:20 -0700 Subject: [PATCH 1/2] fix(agents): reopen profile rooms idempotently --- .../service/agentsRuntime.room.test.js | 50 +++++++++++++++++++ backend/routes/agentsRuntime.ts | 29 +++++++++-- .../V2AgentProfileMemoryWrite.test.tsx | 15 ++++++ frontend/src/v2/agents/V2AgentProfile.tsx | 12 ++--- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/backend/__tests__/service/agentsRuntime.room.test.js b/backend/__tests__/service/agentsRuntime.room.test.js index 11cd8a52e..2d7348907 100644 --- a/backend/__tests__/service/agentsRuntime.room.test.js +++ b/backend/__tests__/service/agentsRuntime.room.test.js @@ -135,6 +135,56 @@ 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('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') diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index b80acb5e4..1dfb469e5 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -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) => ( @@ -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 || ''), })), diff --git a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx index d378ee7a5..41ac67da4 100644 --- a/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentProfileMemoryWrite.test.tsx @@ -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(); + }); }); diff --git a/frontend/src/v2/agents/V2AgentProfile.tsx b/frontend/src/v2/agents/V2AgentProfile.tsx index d727bc939..f95b8519e 100644 --- a/frontend/src/v2/agents/V2AgentProfile.tsx +++ b/frontend/src/v2/agents/V2AgentProfile.tsx @@ -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]); From d923cec3591f2aadbd840d7668e6272a1f31d68b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:26:08 -0700 Subject: [PATCH 2/2] test(agents): cover room-only installation fallback --- .../service/agentsRuntime.room.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/__tests__/service/agentsRuntime.room.test.js b/backend/__tests__/service/agentsRuntime.room.test.js index 2d7348907..fc3937573 100644 --- a/backend/__tests__/service/agentsRuntime.room.test.js +++ b/backend/__tests__/service/agentsRuntime.room.test.js @@ -157,6 +157,31 @@ describe('POST /api/agents/runtime/room — dual-auth (ADR-010 Phase 1)', () => 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')