diff --git a/docsgpt/storage/db/repositories/agents.py b/docsgpt/storage/db/repositories/agents.py index 78c2bb80..8676966b 100644 --- a/docsgpt/storage/db/repositories/agents.py +++ b/docsgpt/storage/db/repositories/agents.py @@ -43,7 +43,7 @@ class AgentsRepository: "limited_token_mode", "limited_request_mode", "allow_system_prompt_override", "shared", "shared_token", "shared_metadata", - "tools", "json_schema", "models", "config", "legacy_mongo_id", + "tools", "json_schema", "models", "config", "resource_sponsors", "legacy_mongo_id", "created_at", "updated_at", "last_used_at", } diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx index fa3a510f..ef92086e 100644 --- a/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx +++ b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx @@ -1,6 +1,9 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + import type { Agent, ResourceSponsor } from '../types'; import SponsoredResourcesNotice from './SponsoredResourcesNotice'; @@ -86,6 +89,36 @@ describe('SponsoredResourcesNotice', () => { expect(container.innerHTML).toBe(''); }); + it('joins names for the app language and does not escape them', async () => { + await i18n.use(initReactI18next).init({ + lng: 'jp', + resources: { + jp: { + translation: { + agents: { + form: { sponsors: { addedBy: '{{person}}: {{names}}' } }, + }, + }, + }, + }, + }); + try { + await render({ + ...baseAgent, + resource_sponsors: [ + sponsor({ id: 'docs/a' }), + sponsor({ id: 'docs/b' }), + ], + }); + const expected = new Intl.ListFormat('ja', { + type: 'conjunction', + }).format(['name-docs/a', 'name-docs/b']); + expect(container.textContent).toBe(`bob@example.com: ${expected}`); + } finally { + await i18n.changeLanguage('en'); + } + }); + it('splits running and no-longer-running items', async () => { await render({ ...baseAgent, diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.tsx index 79d8fd26..42c6c687 100644 --- a/frontend/src/agents/components/SponsoredResourcesNotice.tsx +++ b/frontend/src/agents/components/SponsoredResourcesNotice.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { can, isOwner } from '../../utils/accessUtils'; +import { intlLocale } from '../../utils/dateTimeUtils'; import type { Agent, ResourceSponsor } from '../types'; type SponsoredResourcesNoticeProps = { @@ -34,7 +35,7 @@ export default function SponsoredResourcesNotice({ agent, resolveName, }: SponsoredResourcesNoticeProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const sponsors = agent.resource_sponsors ?? []; const showAttachNote = !isOwner(agent) && can(agent, 'edit'); const active = groupByPerson(sponsors.filter((s) => s.active)); @@ -42,8 +43,11 @@ export default function SponsoredResourcesNotice({ if (!showAttachNote && sponsors.length === 0) return null; + const listFormat = new Intl.ListFormat(intlLocale(i18n.language), { + type: 'conjunction', + }); const names = (items: ResourceSponsor[]) => - items.map((item) => resolveName(item)).join(', '); + listFormat.format(items.map((item) => resolveName(item))); return (
@@ -63,6 +67,7 @@ export default function SponsoredResourcesNotice({ {active.map(([person, items]) => (

{t('agents.form.sponsors.addedBy', { + interpolation: { escapeValue: false }, person, names: names(items), })} @@ -78,6 +83,7 @@ export default function SponsoredResourcesNotice({ {inactive.map(([person, items]) => (

{t('agents.form.sponsors.unavailable', { + interpolation: { escapeValue: false }, person, names: names(items), })} diff --git a/frontend/src/teams/accessSettings.ts b/frontend/src/teams/accessSettings.ts index 02caa3c4..46306127 100644 --- a/frontend/src/teams/accessSettings.ts +++ b/frontend/src/teams/accessSettings.ts @@ -157,8 +157,15 @@ export function capabilityLines( } // A teams API error carries the server's message; anything else (a network -// failure) falls back to the caller's generic copy. -export const errorMessage = (error: unknown, fallback: string): string => - error instanceof Error && error.name === 'TeamsApiError' && error.message - ? error.message +// failure) falls back to the caller's generic copy. Matched by shape, not +// `instanceof`: a thunk's unwrap() rethrows a serialized plain object. +export const errorMessage = (error: unknown, fallback: string): string => { + const e = error as { name?: unknown; message?: unknown } | null; + return typeof e === 'object' && + e !== null && + e.name === 'TeamsApiError' && + typeof e.message === 'string' && + e.message + ? e.message : fallback; +}; diff --git a/frontend/src/teams/teamsSlice.test.ts b/frontend/src/teams/teamsSlice.test.ts index ac4f6ffa..03897815 100644 --- a/frontend/src/teams/teamsSlice.test.ts +++ b/frontend/src/teams/teamsSlice.test.ts @@ -10,6 +10,7 @@ vi.mock('../api/services/teamsService', () => ({ import { configureStore } from '@reduxjs/toolkit'; +import { errorMessage } from './accessSettings'; import reducer, { deleteTeam, loadTeams, Team } from './teamsSlice'; const team: Team = { id: 't1', name: 'Ops', slug: 'ops', owner_id: 'u1' }; @@ -41,6 +42,33 @@ describe('teamsSlice', () => { expect(store.getState().teams.teams).toHaveLength(1); }); + it("surfaces the server's reason through unwrap()", async () => { + // unwrap() rethrows a serialized plain object, not the Error instance. + remove.mockRejectedValue( + Object.assign(new Error('Only the owner can delete'), { + name: 'TeamsApiError', + }), + ); + const store = makeStore(); + const rejection = await store + .dispatch(deleteTeam({ id: 't1', token: null })) + .unwrap() + .catch((error: unknown) => error); + expect(errorMessage(rejection, 'fallback')).toBe( + 'Only the owner can delete', + ); + }); + + it('keeps the fallback for a non-teams rejection', async () => { + remove.mockRejectedValue(new Error('network down')); + const store = makeStore(); + const rejection = await store + .dispatch(deleteTeam({ id: 't1', token: null })) + .unwrap() + .catch((error: unknown) => error); + expect(errorMessage(rejection, 'fallback')).toBe('fallback'); + }); + it('keeps the team when a 2xx body says success:false', async () => { remove.mockResolvedValue({ success: false, message: 'nope' }); const store = makeStore(); diff --git a/tests/storage/db/repositories/test_agents.py b/tests/storage/db/repositories/test_agents.py index df9ea870..a6d9e8a2 100644 --- a/tests/storage/db/repositories/test_agents.py +++ b/tests/storage/db/repositories/test_agents.py @@ -49,6 +49,12 @@ class TestCreate: ) assert doc["legacy_mongo_id"] == "507f1f77bcf86cd799439011" + def test_create_with_resource_sponsors(self, pg_conn): + repo = _repo(pg_conn) + sponsors = {"tool:t1": "editor-1"} + doc = repo.create("u", "a", "draft", resource_sponsors=sponsors) + assert doc["resource_sponsors"] == sponsors + def test_create_normalizes_blank_key_to_null(self, pg_conn): repo = _repo(pg_conn) doc = repo.create("u", "a", "draft", key="")