diff --git a/apps/api/src/handlers/artifacts/__tests__/create.test.ts b/apps/api/src/handlers/artifacts/__tests__/create.test.ts index 369956881..b91a1d663 100644 --- a/apps/api/src/handlers/artifacts/__tests__/create.test.ts +++ b/apps/api/src/handlers/artifacts/__tests__/create.test.ts @@ -111,4 +111,37 @@ describe('createArtifact', () => { signingKey: 'signing-key', }); }); + + it('accepts architecture snapshots through the generic artifact route', async () => { + mockCreateTaskArtifactRecord.mockResolvedValue({ + id: 'art-snapshot', + version: 2, + artifactType: 'architecture-snapshot', + }); + + const response = await createApp().request('http://localhost/artifacts', { + method: 'POST', + body: JSON.stringify({ + taskId: 'task-1', + artifactType: 'architecture-snapshot', + contentType: 'application/json', + path: 'architecture-snapshots/current.json', + size: 100, + }), + headers: { 'content-type': 'application/json' }, + }); + + expect(response.status).toBe(200); + expect(mockCreateTaskArtifactRecord).toHaveBeenCalledWith( + expect.objectContaining({ + artifactType: 'architecture-snapshot', + path: 'architecture-snapshots/current.json', + }), + ); + expect(await response.json()).toMatchObject({ + id: 'art-snapshot', + version: 2, + artifactType: 'architecture-snapshot', + }); + }); }); diff --git a/apps/web/src/components/tasks/ArchitectureSnapshotContent.client.test.tsx b/apps/web/src/components/tasks/ArchitectureSnapshotContent.client.test.tsx new file mode 100644 index 000000000..467458842 --- /dev/null +++ b/apps/web/src/components/tasks/ArchitectureSnapshotContent.client.test.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; + +const { createMermaidPluginMock } = vi.hoisted(() => ({ + createMermaidPluginMock: vi.fn(() => ({ name: 'strict-mermaid' })), +})); + +vi.mock('@streamdown/mermaid', () => ({ + createMermaidPlugin: createMermaidPluginMock, +})); + +vi.mock('streamdown', () => ({ + Streamdown: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/system', () => ({ + Alert: ({ children }: { children: ReactNode }) =>
{children}
, + AlertTitle: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDescription: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Badge: ({ children }: { children: ReactNode }) => {children}, +})); + +import { + ArchitectureSnapshotContent, + architectureSnapshotMermaidConfig, + toMermaidMarkdown, +} from './ArchitectureSnapshotContent'; + +const snapshot = JSON.stringify({ + schemaVersion: 1, + title: 'Artifact publication flow', + mermaid: 'flowchart LR\n Agent --> API --> Store', + sources: [ + { + repository: 'RooCodeInc/Roomote', + path: 'apps/api/src/handlers/artifacts/create.ts', + lineStart: 71, + lineEnd: 104, + description: 'Creates the versioned record.', + }, + ], +}); + +describe('ArchitectureSnapshotContent', () => { + it('labels generated evidence and shows useful source references', () => { + render(); + + expect(screen.getByText('Generated explanatory evidence')).toBeVisible(); + expect( + screen.getByText(/not authoritative architecture documentation/i), + ).toBeVisible(); + expect(screen.getByText('Contract v1')).toBeVisible(); + expect(screen.getByText('RooCodeInc/Roomote')).toBeVisible(); + expect( + screen.getByText('apps/api/src/handlers/artifacts/create.ts:71-104'), + ).toBeVisible(); + expect(screen.getByText('Creates the versioned record.')).toBeVisible(); + }); + + it('uses strict Mermaid rendering', () => { + render(); + + expect(architectureSnapshotMermaidConfig).toEqual({ + securityLevel: 'strict', + suppressErrorRendering: true, + }); + expect(screen.getByTestId('mermaid-source')).toHaveTextContent( + 'flowchart LR Agent --> API --> Store', + ); + }); + + it('contains backticks in a fence that Mermaid source cannot escape', () => { + const markdown = toMermaidMarkdown('flowchart LR\n```\nA --> B'); + + expect(markdown.startsWith('````mermaid\n')).toBe(true); + expect(markdown.endsWith('\n````')).toBe(true); + }); + + it('does not render invalid content as Mermaid', () => { + render( + , + ); + + expect(screen.getByText('Architecture snapshot unavailable')).toBeVisible(); + expect(screen.queryByTestId('mermaid-source')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/tasks/ArchitectureSnapshotContent.tsx b/apps/web/src/components/tasks/ArchitectureSnapshotContent.tsx new file mode 100644 index 000000000..e7b48c8a0 --- /dev/null +++ b/apps/web/src/components/tasks/ArchitectureSnapshotContent.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { Streamdown } from 'streamdown'; +import { createMermaidPlugin } from '@streamdown/mermaid'; + +import { + parseArchitectureSnapshot, + type ArchitectureSnapshot, +} from '@roomote/types'; + +import { + Alert, + AlertDescription, + AlertTitle, + Badge, +} from '@/components/system'; + +export const architectureSnapshotMermaidConfig = { + securityLevel: 'strict', + suppressErrorRendering: true, +} as const; + +const strictMermaidPlugin = createMermaidPlugin({ + config: architectureSnapshotMermaidConfig, +}); + +function toMermaidMarkdown(source: string): string { + const longestBacktickRun = Math.max( + 0, + ...Array.from(source.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); + return `${fence}mermaid\n${source}\n${fence}`; +} + +function formatSourceLocation( + source: ArchitectureSnapshot['sources'][number], +): string { + if (source.lineStart === undefined) return source.path; + if (source.lineEnd === undefined || source.lineEnd === source.lineStart) { + return `${source.path}:${source.lineStart}`; + } + return `${source.path}:${source.lineStart}-${source.lineEnd}`; +} + +export function ArchitectureSnapshotContent({ content }: { content: string }) { + const snapshot = parseArchitectureSnapshot(content); + + if (!snapshot.success) { + return ( +
+ + Architecture snapshot unavailable + + This generated artifact does not match the supported snapshot + contract. Inspect the raw JSON or download the artifact instead. + + +
+ ); + } + + return ( +
+ + Generated explanatory evidence + + This snapshot helps review the changed system boundary. It is not + authoritative architecture documentation. + + + +
+
+

+ {snapshot.data.title} +

+ + Contract v{snapshot.data.schemaVersion} + +
+
+ + {toMermaidMarkdown(snapshot.data.mermaid)} + +
+
+ +
+

+ Source references +

+
    + {snapshot.data.sources.map((source, index) => ( +
  • +
    + {source.repository} +
    + + {formatSourceLocation(source)} + + {source.description ? ( +

    + {source.description} +

    + ) : null} +
  • + ))} +
+
+
+ ); +} + +export { toMermaidMarkdown }; diff --git a/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx b/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx index 2c2268df2..d1173804d 100644 --- a/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx +++ b/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx @@ -51,6 +51,7 @@ vi.mock('@streamdown/math', () => ({ })); vi.mock('@streamdown/mermaid', () => ({ + createMermaidPlugin: vi.fn(() => ({})), mermaid: {}, })); @@ -62,19 +63,23 @@ vi.mock('sonner', () => ({ }, })); -vi.mock('@roomote/types', () => ({ - ALL_REPOSITORIES: [], - DEFAULT_MANAGED_DEPLOYMENT_ACCESS: { - state: 'active', - reason: null, - revision: 1, - effectiveAt: '2026-01-01T00:00:00.000Z', - restrictionStartsAt: null, - remediationUrl: null, - }, - MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE: - 'New tasks are paused due to a billing issue. Please check billing.', -})); +vi.mock('@roomote/types', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ALL_REPOSITORIES: [], + DEFAULT_MANAGED_DEPLOYMENT_ACCESS: { + state: 'active', + reason: null, + revision: 1, + effectiveAt: '2026-01-01T00:00:00.000Z', + restrictionStartsAt: null, + remediationUrl: null, + }, + MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE: + 'New tasks are paused due to a billing issue. Please check billing.', + }; +}); vi.mock('@/trpc/client', () => ({ useTRPC: () => ({ @@ -180,6 +185,12 @@ vi.mock('@/components/system', () => ({ ), BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}, MediaViewerImage: () =>
image
, + Alert: ({ children }: { children: ReactNode }) =>
{children}
, + AlertTitle: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDescription: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Badge: ({ children }: { children: ReactNode }) => {children}, })); vi.mock('@/components/ai-elements', () => ({ @@ -253,6 +264,41 @@ describe('ArtifactViewerContent', () => { expect(screen.queryByText('Type: visual-proof')).not.toBeInTheDocument(); }); + it('renders architecture snapshots in the artifact evidence viewer', () => { + render( + API', + sources: [ + { + repository: 'RooCodeInc/Roomote', + path: 'apps/api/src/handlers/artifacts/create.ts', + }, + ], + }), + }} + />, + ); + + expect(screen.getByText('Generated explanatory evidence')).toBeVisible(); + expect(screen.getByText('Source references')).toBeVisible(); + expect(screen.getByText('Raw')).toBeVisible(); + expect(screen.queryByText('Build this')).not.toBeInTheDocument(); + }); + it('hides the Build action when a markdown plan has no fetched content', () => { render( = { json: 'json', @@ -227,16 +228,24 @@ export function ArtifactViewerContent({ const isMarkdown = artifact.contentType.includes('markdown') || artifact.path.endsWith('.md'); + const isArchitectureSnapshot = + artifact.artifactType === 'architecture-snapshot'; const isImage = artifact.contentType.startsWith('image/'); const isVideo = artifact.contentType.startsWith('video/'); const isPDF = artifact.contentType === 'application/pdf'; const isText = - !isMarkdown && !isImage && !isVideo && !isPDF && !!artifact.content; + !isMarkdown && + !isArchitectureSnapshot && + !isImage && + !isVideo && + !isPDF && + !!artifact.content; const language = getLanguageFromPath(artifact.path); const canRender = isText || (isMarkdown && artifact.content) || + (isArchitectureSnapshot && artifact.content) || ((isImage || isVideo || isPDF) && artifact.downloadUrl); const taskPayload = task?.taskRun?.payload as TaskPayload | undefined; @@ -399,7 +408,7 @@ export function ArtifactViewerContent({
- {canRender && isMarkdown && ( + {canRender && (isMarkdown || isArchitectureSnapshot) && (