From 8a44a1579a7a5cab0b7bc1d8f4a9757812bf5f63 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Mon, 13 Apr 2026 17:45:05 +0200 Subject: [PATCH 1/8] feat: add versioned admin agent markdown editor --- console/src/api/client.ts | 74 +++ console/src/api/types.ts | 48 ++ console/src/components/sidebar/navigation.ts | 2 + console/src/router.tsx | 8 + console/src/routes/agents.test.tsx | 296 +++++++++++ console/src/routes/agents.tsx | 502 ++++++++++++++++++ src/gateway/gateway-http-server.ts | 404 ++++++++++++--- src/gateway/gateway-service.ts | 434 +++++++++++++++- src/gateway/gateway-types.ts | 48 ++ src/workspace.ts | 14 +- tests/gateway-http-server.test.ts | 515 +++++++++++++++++++ tests/gateway-service.agent-markdown.test.ts | 168 ++++++ 12 files changed, 2423 insertions(+), 90 deletions(-) create mode 100644 console/src/routes/agents.test.tsx create mode 100644 console/src/routes/agents.tsx create mode 100644 tests/gateway-service.agent-markdown.test.ts diff --git a/console/src/api/client.ts b/console/src/api/client.ts index d5380a60..513b34e8 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -1,6 +1,10 @@ import type { AdminAdaptiveSkillAmendmentsResponse, AdminAdaptiveSkillHealthResponse, + AdminAgent, + AdminAgentMarkdownFileResponse, + AdminAgentMarkdownRevisionResponse, + AdminAgentsResponse, AdminAuditResponse, AdminChannelConfig, AdminChannelsResponse, @@ -183,6 +187,76 @@ export function fetchAgentsOverview(token: string): Promise { return requestJson('/api/agents', { token }); } +export async function fetchAdminAgents(token: string): Promise { + const payload = await requestJson('/api/admin/agents', { + token, + }); + return payload.agents; +} + +export function fetchAdminAgentMarkdownFile( + token: string, + params: { + agentId: string; + fileName: string; + }, +): Promise { + return requestJson( + `/api/admin/agents/${encodeURIComponent(params.agentId)}/files/${encodeURIComponent(params.fileName)}`, + { token }, + ); +} + +export function saveAdminAgentMarkdownFile( + token: string, + params: { + agentId: string; + fileName: string; + content: string; + }, +): Promise { + return requestJson( + `/api/admin/agents/${encodeURIComponent(params.agentId)}/files/${encodeURIComponent(params.fileName)}`, + { + token, + method: 'PUT', + body: { content: params.content }, + }, + ); +} + +export function fetchAdminAgentMarkdownRevision( + token: string, + params: { + agentId: string; + fileName: string; + revisionId: string; + }, +): Promise { + return requestJson( + `/api/admin/agents/${encodeURIComponent(params.agentId)}/files/${encodeURIComponent(params.fileName)}/revisions/${encodeURIComponent(params.revisionId)}`, + { token }, + ); +} + +export function restoreAdminAgentMarkdownRevision( + token: string, + params: { + agentId: string; + fileName: string; + revisionId: string; + }, +): Promise { + return requestJson( + `/api/admin/agents/${encodeURIComponent(params.agentId)}/files/${encodeURIComponent(params.fileName)}/revisions/${encodeURIComponent(params.revisionId)}/restore`, + { + token, + method: 'POST', + body: {}, + }, + ); +} + export function fetchJobsContext( token: string, ): Promise { diff --git a/console/src/api/types.ts b/console/src/api/types.ts index 8dcb9e0c..89a33b86 100644 --- a/console/src/api/types.ts +++ b/console/src/api/types.ts @@ -599,6 +599,54 @@ export interface AdminSchedulerResponse { jobs: AdminSchedulerJob[]; } +export interface AdminAgentMarkdownFile { + name: string; + path: string; + exists: boolean; + updatedAt: string | null; + sizeBytes: number | null; +} + +export interface AdminAgentMarkdownRevision { + id: string; + createdAt: string; + sizeBytes: number; + sha256: string; + source: 'save' | 'restore'; +} + +export interface AdminAgent { + id: string; + name: string | null; + model: string | null; + skills: string[] | null; + chatbotId: string | null; + enableRag: boolean | null; + workspace: string | null; + workspacePath: string; + markdownFiles: AdminAgentMarkdownFile[]; +} + +export interface AdminAgentsResponse { + agents: AdminAgent[]; +} + +export interface AdminAgentMarkdownFileResponse { + agent: AdminAgent; + file: AdminAgentMarkdownFile & { + content: string; + revisions: AdminAgentMarkdownRevision[]; + }; +} + +export interface AdminAgentMarkdownRevisionResponse { + agent: AdminAgent; + fileName: string; + revision: AdminAgentMarkdownRevision & { + content: string; + }; +} + export interface AgentCard { id: string; name: string | null; diff --git a/console/src/components/sidebar/navigation.ts b/console/src/components/sidebar/navigation.ts index 712568b2..382d169d 100644 --- a/console/src/components/sidebar/navigation.ts +++ b/console/src/components/sidebar/navigation.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react'; import { + Agents, Audit, Channels, Cog, @@ -54,6 +55,7 @@ export const SIDEBAR_NAV_GROUPS: ReadonlyArray = [ { label: 'Configuration', items: [ + { to: '/agents', label: 'Agent Files', icon: Agents }, { to: '/skills', label: 'Skills', icon: Skills }, { to: '/plugins', label: 'Plugins', icon: Plugins }, { to: '/tools', label: 'Tools', icon: Tools }, diff --git a/console/src/router.tsx b/console/src/router.tsx index d1e22aa4..89f90f1a 100644 --- a/console/src/router.tsx +++ b/console/src/router.tsx @@ -6,6 +6,7 @@ import { } from '@tanstack/react-router'; import { lazy, Suspense } from 'react'; import { AppShell } from './components/app-shell'; +import { AgentFilesPage } from './routes/agents'; import { AuditPage } from './routes/audit'; import { ChannelsPage } from './routes/channels'; import { ConfigPage } from './routes/config'; @@ -52,6 +53,12 @@ const dashboardRoute = createRoute({ component: DashboardPage, }); +const agentFilesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/agents', + component: AgentFilesPage, +}); + const terminalRoute = createRoute({ getParentRoute: () => rootRoute, path: '/terminal', @@ -138,6 +145,7 @@ const toolsRoute = createRoute({ const routeTree = rootRoute.addChildren([ dashboardRoute, + agentFilesRoute, terminalRoute, gatewayRoute, sessionsRoute, diff --git a/console/src/routes/agents.test.tsx b/console/src/routes/agents.test.tsx new file mode 100644 index 00000000..e5773156 --- /dev/null +++ b/console/src/routes/agents.test.tsx @@ -0,0 +1,296 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + AdminAgent, + AdminAgentMarkdownFileResponse, + AdminAgentMarkdownRevisionResponse, +} from '../api/types'; +import { ToastProvider } from '../components/toast'; +import { AgentFilesPage } from './agents'; + +const fetchAdminAgentsMock = vi.fn<() => Promise>(); +const fetchAdminAgentMarkdownFileMock = + vi.fn< + ( + token: string, + params: { agentId: string; fileName: string }, + ) => Promise + >(); +const fetchAdminAgentMarkdownRevisionMock = + vi.fn< + ( + token: string, + params: { agentId: string; fileName: string; revisionId: string }, + ) => Promise + >(); +const restoreAdminAgentMarkdownRevisionMock = vi.fn(); +const saveAdminAgentMarkdownFileMock = vi.fn(); +const useAuthMock = vi.fn(); + +vi.mock('../api/client', () => ({ + fetchAdminAgents: () => fetchAdminAgentsMock(), + fetchAdminAgentMarkdownFile: ( + token: string, + params: { agentId: string; fileName: string }, + ) => fetchAdminAgentMarkdownFileMock(token, params), + fetchAdminAgentMarkdownRevision: ( + token: string, + params: { agentId: string; fileName: string; revisionId: string }, + ) => fetchAdminAgentMarkdownRevisionMock(token, params), + restoreAdminAgentMarkdownRevision: ( + token: string, + params: { agentId: string; fileName: string; revisionId: string }, + ) => restoreAdminAgentMarkdownRevisionMock(token, params), + saveAdminAgentMarkdownFile: ( + token: string, + params: { agentId: string; fileName: string; content: string }, + ) => saveAdminAgentMarkdownFileMock(token, params), +})); + +vi.mock('../auth', () => ({ + useAuth: () => useAuthMock(), +})); + +function makeAgent(overrides: Partial): AdminAgent { + return { + id: 'main', + name: 'Main Agent', + model: 'gpt-5', + skills: null, + chatbotId: null, + enableRag: true, + workspace: null, + workspacePath: '/tmp/main/workspace', + markdownFiles: [ + { + name: 'AGENTS.md', + path: '/tmp/main/workspace/AGENTS.md', + exists: true, + updatedAt: '2026-04-13T10:00:00.000Z', + sizeBytes: 120, + }, + { + name: 'USER.md', + path: '/tmp/main/workspace/USER.md', + exists: false, + updatedAt: null, + sizeBytes: null, + }, + ], + ...overrides, + }; +} + +function makeDocument( + agent: AdminAgent, + fileName: string, + content: string, +): AdminAgentMarkdownFileResponse { + const file = + agent.markdownFiles.find((entry) => entry.name === fileName) || + agent.markdownFiles[0]; + if (!file) { + throw new Error(`Missing file ${fileName}`); + } + return { + agent, + file: { + ...file, + content, + revisions: [ + { + id: 'rev-1', + createdAt: '2026-04-13T09:00:00.000Z', + sizeBytes: 80, + sha256: 'abc123', + source: 'save', + }, + ], + }, + }; +} + +function renderPage(): QueryClient { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + render( + + + + + , + ); + + return queryClient; +} + +describe('AgentFilesPage', () => { + beforeEach(() => { + fetchAdminAgentsMock.mockReset(); + fetchAdminAgentMarkdownFileMock.mockReset(); + fetchAdminAgentMarkdownRevisionMock.mockReset(); + restoreAdminAgentMarkdownRevisionMock.mockReset(); + saveAdminAgentMarkdownFileMock.mockReset(); + useAuthMock.mockReset(); + useAuthMock.mockReturnValue({ + token: 'test-token', + }); + }); + + it('loads the selected agent markdown file and switches agents', async () => { + const mainAgent = makeAgent({}); + const writerAgent = makeAgent({ + id: 'writer', + name: 'Writer', + model: null, + workspacePath: '/tmp/writer/workspace', + markdownFiles: [ + { + name: 'AGENTS.md', + path: '/tmp/writer/workspace/AGENTS.md', + exists: false, + updatedAt: null, + sizeBytes: null, + }, + { + name: 'USER.md', + path: '/tmp/writer/workspace/USER.md', + exists: true, + updatedAt: '2026-04-13T11:00:00.000Z', + sizeBytes: 42, + }, + ], + }); + + fetchAdminAgentsMock.mockResolvedValue([mainAgent, writerAgent]); + fetchAdminAgentMarkdownFileMock.mockImplementation( + async (_token, params) => + params.agentId === 'writer' + ? makeDocument(writerAgent, params.fileName, '# Writer Rules') + : makeDocument(mainAgent, params.fileName, '# Main Rules'), + ); + + renderPage(); + + expect(await screen.findByDisplayValue('# Main Rules')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /writer/i })); + + expect(await screen.findByDisplayValue('# Writer Rules')).not.toBeNull(); + expect(fetchAdminAgentMarkdownFileMock).toHaveBeenCalledWith('test-token', { + agentId: 'writer', + fileName: 'AGENTS.md', + }); + }); + + it('saves edited markdown content for the selected agent file', async () => { + const agent = makeAgent({}); + fetchAdminAgentsMock.mockResolvedValue([agent]); + fetchAdminAgentMarkdownFileMock.mockResolvedValue( + makeDocument(agent, 'AGENTS.md', '# Original Rules'), + ); + saveAdminAgentMarkdownFileMock.mockResolvedValue( + makeDocument(agent, 'AGENTS.md', '# Updated Rules'), + ); + + renderPage(); + + await screen.findByDisplayValue('# Original Rules'); + const editor = (await screen.findByLabelText( + 'AGENTS.md', + )) as HTMLTextAreaElement; + fireEvent.change(editor, { target: { value: '# Updated Rules' } }); + await waitFor(() => expect(editor.value).toBe('# Updated Rules')); + fireEvent.click(screen.getByRole('button', { name: 'Save Markdown' })); + + await waitFor(() => + expect(saveAdminAgentMarkdownFileMock).toHaveBeenCalledWith( + 'test-token', + { + agentId: 'main', + fileName: 'AGENTS.md', + content: '# Updated Rules', + }, + ), + ); + expect(editor.value).toBe('# Updated Rules'); + }); + + it('loads and restores a saved markdown revision', async () => { + const agent = makeAgent({}); + fetchAdminAgentsMock.mockResolvedValue([agent]); + fetchAdminAgentMarkdownFileMock.mockResolvedValue( + makeDocument(agent, 'AGENTS.md', '# Current Rules'), + ); + fetchAdminAgentMarkdownRevisionMock.mockResolvedValue({ + agent, + fileName: 'AGENTS.md', + revision: { + id: 'rev-1', + createdAt: '2026-04-13T09:00:00.000Z', + sizeBytes: 80, + sha256: 'abc123', + source: 'save', + content: '# Previous Rules', + }, + }); + restoreAdminAgentMarkdownRevisionMock.mockResolvedValue( + makeDocument(agent, 'AGENTS.md', '# Previous Rules'), + ); + + renderPage(); + + await screen.findByDisplayValue('# Current Rules'); + fireEvent.click( + screen.getByText(/80 bytes/i).closest('button') as HTMLButtonElement, + ); + + expect(await screen.findByDisplayValue('# Previous Rules')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Restore Version' })); + + await waitFor(() => + expect(restoreAdminAgentMarkdownRevisionMock).toHaveBeenCalledWith( + 'test-token', + { + agentId: 'main', + fileName: 'AGENTS.md', + revisionId: 'rev-1', + }, + ), + ); + }); + + it('preserves dirty editor content when the selected file refetches', async () => { + const agent = makeAgent({}); + fetchAdminAgentsMock.mockResolvedValue([agent]); + fetchAdminAgentMarkdownFileMock.mockResolvedValue( + makeDocument(agent, 'AGENTS.md', '# Original Rules'), + ); + + const queryClient = renderPage(); + + await screen.findByDisplayValue('# Original Rules'); + const editor = (await screen.findByLabelText( + 'AGENTS.md', + )) as HTMLTextAreaElement; + fireEvent.change(editor, { target: { value: '# Draft Rules' } }); + await waitFor(() => expect(editor.value).toBe('# Draft Rules')); + + fetchAdminAgentMarkdownFileMock.mockResolvedValue( + makeDocument(agent, 'AGENTS.md', '# Refetched Rules'), + ); + await queryClient.invalidateQueries({ + queryKey: ['admin-agent-markdown', 'test-token', 'main', 'AGENTS.md'], + }); + + await waitFor(() => + expect(fetchAdminAgentMarkdownFileMock).toHaveBeenCalledTimes(2), + ); + expect(editor.value).toBe('# Draft Rules'); + }); +}); diff --git a/console/src/routes/agents.tsx b/console/src/routes/agents.tsx new file mode 100644 index 00000000..475498b1 --- /dev/null +++ b/console/src/routes/agents.tsx @@ -0,0 +1,502 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; +import { + fetchAdminAgentMarkdownFile, + fetchAdminAgentMarkdownRevision, + fetchAdminAgents, + restoreAdminAgentMarkdownRevision, + saveAdminAgentMarkdownFile, +} from '../api/client'; +import type { AdminAgent } from '../api/types'; +import { useAuth } from '../auth'; +import { useToast } from '../components/toast'; +import { PageHeader, Panel } from '../components/ui'; +import { getErrorMessage } from '../lib/error-message'; +import { formatDateTime, formatRelativeTime } from '../lib/format'; + +function getDefaultFileName(agent: AdminAgent | null): string | null { + if (!agent) return null; + return ( + agent.markdownFiles.find((file) => file.exists)?.name || + agent.markdownFiles[0]?.name || + null + ); +} + +function getSelectedDocumentKey( + agentId: string | null | undefined, + fileName: string | null | undefined, +): string | null { + if (!agentId || !fileName) return null; + return `${agentId}:${fileName}`; +} + +export function AgentFilesPage() { + const auth = useAuth(); + const queryClient = useQueryClient(); + const toast = useToast(); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [selectedFileName, setSelectedFileName] = useState(null); + const [selectedRevisionId, setSelectedRevisionId] = useState( + null, + ); + const [draftContent, setDraftContent] = useState(''); + const hydratedDocumentKeyRef = useRef(null); + const hydratedContentRef = useRef(''); + + const agentsQuery = useQuery({ + queryKey: ['admin-agents', auth.token], + queryFn: () => fetchAdminAgents(auth.token), + }); + + const selectedAgent = + agentsQuery.data?.find((agent) => agent.id === selectedAgentId) || + agentsQuery.data?.[0] || + null; + + useEffect(() => { + if (selectedAgent && selectedAgent.id !== selectedAgentId) { + setSelectedAgentId(selectedAgent.id); + } + }, [selectedAgent, selectedAgentId]); + + useEffect(() => { + if (!selectedAgent) { + if (selectedFileName !== null) { + setSelectedFileName(null); + setSelectedRevisionId(null); + } + return; + } + const availableFile = + selectedAgent.markdownFiles.find((file) => file.name === selectedFileName) + ?.name || getDefaultFileName(selectedAgent); + if (availableFile !== selectedFileName) { + setSelectedFileName(availableFile); + setSelectedRevisionId(null); + } + }, [selectedAgent, selectedFileName]); + + const selectedFileSummary = + selectedAgent?.markdownFiles.find( + (file) => file.name === selectedFileName, + ) || null; + const selectedDocumentKey = getSelectedDocumentKey( + selectedAgent?.id, + selectedFileName, + ); + const selectedFileQueryKey = [ + 'admin-agent-markdown', + auth.token, + selectedAgent?.id || '', + selectedFileName || '', + ] as const; + + const fileQuery = useQuery({ + queryKey: selectedFileQueryKey, + queryFn: () => + fetchAdminAgentMarkdownFile(auth.token, { + agentId: selectedAgent?.id || '', + fileName: selectedFileName || '', + }), + enabled: Boolean(selectedAgent?.id && selectedFileName), + refetchOnWindowFocus: false, + }); + + const revisionQuery = useQuery({ + queryKey: [ + 'admin-agent-markdown-revision', + auth.token, + selectedAgent?.id || '', + selectedFileName || '', + selectedRevisionId || '', + ], + queryFn: () => + fetchAdminAgentMarkdownRevision(auth.token, { + agentId: selectedAgent?.id || '', + fileName: selectedFileName || '', + revisionId: selectedRevisionId || '', + }), + enabled: Boolean( + selectedAgent?.id && selectedFileName && selectedRevisionId, + ), + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (!selectedDocumentKey) { + hydratedDocumentKeyRef.current = null; + hydratedContentRef.current = ''; + setDraftContent(''); + return; + } + if (!fileQuery.data) return; + const nextContent = fileQuery.data.file.content; + const shouldHydrateDraft = + hydratedDocumentKeyRef.current !== selectedDocumentKey || + draftContent === hydratedContentRef.current; + if (!shouldHydrateDraft) return; + hydratedDocumentKeyRef.current = selectedDocumentKey; + hydratedContentRef.current = nextContent; + setDraftContent(nextContent); + }, [draftContent, fileQuery.data, selectedDocumentKey]); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (!selectedAgent || !selectedFileName) { + throw new Error('Select an agent and markdown file first.'); + } + return saveAdminAgentMarkdownFile(auth.token, { + agentId: selectedAgent.id, + fileName: selectedFileName, + content: draftContent, + }); + }, + onSuccess: (payload) => { + const nextDocumentKey = getSelectedDocumentKey( + payload.agent.id, + payload.file.name, + ); + queryClient.setQueryData( + [ + 'admin-agent-markdown', + auth.token, + payload.agent.id, + payload.file.name, + ], + payload, + ); + void queryClient.invalidateQueries({ + queryKey: ['admin-agents', auth.token], + }); + hydratedDocumentKeyRef.current = nextDocumentKey; + hydratedContentRef.current = payload.file.content; + setDraftContent(payload.file.content); + toast.success( + `Saved ${payload.file.name} for ${payload.agent.name || payload.agent.id}.`, + ); + }, + onError: (error) => { + toast.error('Save failed', getErrorMessage(error)); + }, + }); + + const restoreMutation = useMutation({ + mutationFn: async () => { + if (!selectedAgent || !selectedFileName || !selectedRevisionId) { + throw new Error('Select a version to restore first.'); + } + return restoreAdminAgentMarkdownRevision(auth.token, { + agentId: selectedAgent.id, + fileName: selectedFileName, + revisionId: selectedRevisionId, + }); + }, + onSuccess: (payload) => { + const nextDocumentKey = getSelectedDocumentKey( + payload.agent.id, + payload.file.name, + ); + queryClient.setQueryData( + [ + 'admin-agent-markdown', + auth.token, + payload.agent.id, + payload.file.name, + ], + payload, + ); + void queryClient.invalidateQueries({ + queryKey: ['admin-agents', auth.token], + }); + hydratedDocumentKeyRef.current = nextDocumentKey; + hydratedContentRef.current = payload.file.content; + setDraftContent(payload.file.content); + toast.success(`Restored ${payload.file.name} from version history.`); + }, + onError: (error) => { + toast.error('Restore failed', getErrorMessage(error)); + }, + }); + + const existingFileCount = + selectedAgent?.markdownFiles.filter((file) => file.exists).length || 0; + const totalFileCount = selectedAgent?.markdownFiles.length || 0; + const isDirty = + Boolean(fileQuery.data) && + draftContent !== (fileQuery.data?.file.content || ''); + + return ( +
+ + +
+ + {agentsQuery.isLoading ? ( +
Loading agents...
+ ) : !agentsQuery.data?.length ? ( +
No agents are registered yet.
+ ) : ( +
+ {agentsQuery.data.map((agent) => { + const presentFiles = agent.markdownFiles.filter( + (file) => file.exists, + ).length; + return ( + + ); + })} +
+ )} +
+ + + {!selectedAgent ? ( +
+ Select an agent to edit its files. +
+ ) : !selectedFileName ? ( +
+ This agent does not expose editable markdown files. +
+ ) : ( +
+
+
+ Agent + {selectedAgent.name || selectedAgent.id} +
+
+ Model + {selectedAgent.model || 'runtime default'} +
+
+ Workspace + {selectedAgent.workspacePath} +
+
+ Markdown files + + {existingFileCount}/{totalFileCount} present + +
+
+ + + + {selectedFileSummary ? ( +
+ + {selectedFileSummary.exists + ? `Last updated ${formatRelativeTime(selectedFileSummary.updatedAt || '')} · ${formatDateTime(selectedFileSummary.updatedAt)}` + : 'File not created yet'} + +

{selectedFileSummary.path}

+
+ ) : null} + + {fileQuery.isLoading ? ( +
Loading markdown file...
+ ) : ( + <> +