diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e40f54..b416be4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,9 @@ jobs: run: npm run build - name: Run tests - run: npm test -- --coverage --watchAll=false || true + run: npm test -- --coverage --watchAll=false + env: + CI: true - name: Upload build artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63d3962..fbb1961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,11 +46,16 @@ jobs: npm install fi + - name: Typecheck + run: npx tsc --noEmit -p tsconfig.json + - name: Run linter - run: npm run lint || true + run: npm run lint - name: Run tests - run: npm test -- --coverage --watchAll=false || true + run: npm test -- --coverage --watchAll=false + env: + CI: true - name: Build application run: npm run build diff --git a/package-lock.json b/package-lock.json index 7443ea2..af0fb4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,9 +35,19 @@ "zustand": "^4.3.8" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/jest": "^27.5.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -3511,6 +3521,116 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -3529,6 +3649,14 @@ "node": ">=10.13.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6313,6 +6441,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssdb": { "version": "7.11.2", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.11.2.tgz", @@ -6846,6 +6981,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -9524,6 +9667,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -11635,6 +11788,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -12315,6 +12479,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", @@ -15110,6 +15284,20 @@ "node": ">=6.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16666,6 +16854,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/package.json b/package.json index a28d45a..f0ff4a0 100644 --- a/package.json +++ b/package.json @@ -4,31 +4,31 @@ "private": true, "description": "A modern visual console for debugging and testing GoLangGraph agents", "dependencies": { + "@headlessui/react": "^1.7.15", + "@heroicons/react": "^2.0.18", "@types/node": "^16.18.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@types/react-syntax-highlighter": "^15.5.0", + "@types/uuid": "^9.0.0", + "autoprefixer": "^10.4.0", + "axios": "^1.4.0", + "clsx": "^1.2.1", + "date-fns": "^2.30.0", + "lucide-react": "^0.263.0", + "postcss": "^8.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-scripts": "5.0.1", - "typescript": "^4.9.5", - "web-vitals": "^2.1.0", - "axios": "^1.4.0", + "react-hot-toast": "^2.4.0", "react-markdown": "^8.0.7", + "react-router-dom": "^6.14.0", + "react-scripts": "5.0.1", "react-syntax-highlighter": "^15.5.0", - "@types/react-syntax-highlighter": "^15.5.0", - "lucide-react": "^0.263.0", "tailwindcss": "^3.3.0", - "autoprefixer": "^10.4.0", - "postcss": "^8.4.0", + "typescript": "^4.9.5", "uuid": "^9.0.0", - "@types/uuid": "^9.0.0", - "date-fns": "^2.30.0", - "clsx": "^1.2.1", - "react-hot-toast": "^2.4.0", - "zustand": "^4.3.8", - "react-router-dom": "^6.14.0", - "@headlessui/react": "^1.7.15", - "@heroicons/react": "^2.0.18" + "web-vitals": "^2.1.0", + "zustand": "^4.3.8" }, "scripts": { "start": "react-scripts start", @@ -48,6 +48,12 @@ "react-hooks/exhaustive-deps": "warn" } }, + "jest": { + "transformIgnorePatterns": [ + "[/\\\\]node_modules[/\\\\](?!(react-markdown|react-syntax-highlighter|vfile|unist-|unified|bail|is-plain-obj|trough|remark-|mdast-util-|micromark|decode-named-character-reference|character-|property-information|hast-util-|space-separated-tokens|comma-separated-tokens|trim-lines|zwitch|longest-streak|escape-string-regexp|markdown-table|ccount|devlop|html-void-elements|stringify-entities|web-namespaces))", + "^.+\\.module\\.(css|sass|scss)$" + ] + }, "browserslist": { "production": [ ">0.2%", @@ -61,6 +67,9 @@ ] }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/jest": "^27.5.0" } -} \ No newline at end of file +} diff --git a/src/api/__tests__/client.test.ts b/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..86ee05e --- /dev/null +++ b/src/api/__tests__/client.test.ts @@ -0,0 +1,246 @@ +/** + * Contract tests for the GoLangGraph API client. + * + * The fixtures below are the payloads the Go server actually produces; they + * were taken from the end-to-end suite in the GoLangGraph repository + * (test/e2e/studio_compat_test.go), which drives a live server. A type + * definition that merely compiles proves nothing about the wire format, so + * these tests assert the client reads the real shapes. + */ + +import { createApiClient, ApiError, extractOutputText, summariseToolCalls } from '../client'; + +type FetchMock = jest.Mock, [RequestInfo | URL, RequestInit?]>; + +/** Builds a fetch stub returning one JSON body. */ +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: async () => body, + } as Response; +} + +let fetchMock: FetchMock; + +beforeEach(() => { + fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +const client = () => createApiClient({ baseUrl: 'http://localhost:8080', apiKey: 'test-key' }); + +describe('request plumbing', () => { + it('sends the API key the server expects', async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: 'healthy' })); + + await client().health(); + + const [, init] = fetchMock.mock.calls[0]; + const headers = (init?.headers ?? {}) as Record; + expect(headers['X-API-Key']).toBe('test-key'); + expect(headers['Content-Type']).toBe('application/json'); + }); + + it('surfaces the server error field', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ error: 'graph not found', timestamp: '2024-01-01T00:00:00Z' }, 404), + ); + + await expect(client().getAgent('missing')).rejects.toMatchObject({ + name: 'ApiError', + status: 404, + message: 'graph not found', + }); + }); + + it('reports an unreachable server rather than throwing a raw network error', async () => { + fetchMock.mockRejectedValue(new Error('connection refused')); + + const error = await client().health().catch((e) => e); + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(0); + expect(error.message).toContain('connection refused'); + }); + + it('rejects with the status when the error body is not JSON', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + json: async () => { + throw new Error('not json'); + }, + } as unknown as Response); + + await expect(client().health()).rejects.toMatchObject({ status: 502 }); + }); +}); + +describe('agents', () => { + // The server returns full configurations; it previously returned bare ID + // strings, which left every rendered field undefined. + const agentConfig = { + id: 'studio-agent', + name: 'Studio Agent', + type: 'chat', + model: 'fake-model', + provider: 'fake', + system_prompt: 'You are helpful.', + temperature: 0.7, + max_tokens: 1000, + max_iterations: 10, + tools: ['calculator'], + enable_streaming: true, + timeout: 30000000000, + metadata: {}, + }; + + it('reads agent configurations from the list endpoint', async () => { + fetchMock.mockResolvedValue(jsonResponse({ agents: [agentConfig] })); + + const agents = await client().listAgents(); + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe('Studio Agent'); + expect(agents[0].type).toBe('chat'); + expect(agents[0].tools).toEqual(['calculator']); + }); + + it('treats a missing agents field as an empty list', async () => { + fetchMock.mockResolvedValue(jsonResponse({})); + await expect(client().listAgents()).resolves.toEqual([]); + }); + + it('unwraps a single agent', async () => { + fetchMock.mockResolvedValue(jsonResponse({ agent: agentConfig })); + await expect(client().getAgent('studio-agent')).resolves.toMatchObject({ id: 'studio-agent' }); + }); +}); + +describe('executions', () => { + // Snake_case, matching agent.AgentExecution's JSON tags. The types + // previously declared Go's default PascalCase, so every field read as + // undefined and the debug view showed a blank result for a successful run. + const execution = { + id: 'exec-1', + timestamp: '2024-01-01T00:00:00Z', + input: 'hello', + output: 'hi there', + structured_output: 'hi there', + tool_calls: null, + duration: 1500000, + success: true, + metadata: {}, + execution_path: ['chat'], + }; + + it('reads an execution in the shape the server sends', async () => { + fetchMock.mockResolvedValue(jsonResponse({ execution })); + + const result = await client().executeAgent('studio-agent', 'hello'); + expect(result.success).toBe(true); + expect(result.output).toBe('hi there'); + expect(result.duration).toBe(1500000); + expect(result.execution_path).toEqual(['chat']); + }); + + it('reads a failed execution and its reason', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + execution: { ...execution, success: false, error: 'model backend is offline' }, + }), + ); + + const result = await client().executeAgent('studio-agent', 'hello'); + expect(result.success).toBe(false); + expect(result.error).toBe('model backend is offline'); + }); + + it('reads history', async () => { + fetchMock.mockResolvedValue(jsonResponse({ history: [execution, execution] })); + await expect(client().getAgentHistory('studio-agent')).resolves.toHaveLength(2); + }); + + it('treats a missing history field as an empty list', async () => { + fetchMock.mockResolvedValue(jsonResponse({})); + await expect(client().getAgentHistory('a')).resolves.toEqual([]); + }); +}); + +describe('tools and providers', () => { + it('reads tool names', async () => { + fetchMock.mockResolvedValue(jsonResponse({ tools: ['calculator', 'time'] })); + await expect(client().listTools()).resolves.toEqual(['calculator', 'time']); + }); + + // Providers are described objects; the endpoint previously returned bare + // names, which left ProviderInfo.name undefined. + it('reads provider descriptions', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + providers: [{ name: 'ollama', type: 'ollama', endpoint: 'http://localhost:11434', model: 'gemma3:1b' }], + }), + ); + + const providers = await client().listProviders(); + expect(providers[0].name).toBe('ollama'); + expect(providers[0].model).toBe('gemma3:1b'); + }); +}); + +describe('graph topology', () => { + // The server sends node.id/name/type and edge.from/to. The store maps these + // directly, so a change here breaks the graph view. + it('reads the topology the graph view renders', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + graph_id: 'studio-workflow', + topology: { + nodes: [ + { id: 'ingest', name: 'Ingest', type: 'node', is_start: true, is_end: false }, + { id: 'decide', name: 'Decide', type: 'node', is_start: false, is_end: false }, + ], + edges: [ + { from: 'ingest', to: 'decide', conditional: false }, + { from: 'decide', to: 'long', conditional: true, route_key: 'long' }, + ], + }, + }), + ); + + const topology = await client().getGraphTopology('studio-workflow'); + expect(topology.graph_id).toBe('studio-workflow'); + expect(topology.topology.nodes).toHaveLength(2); + expect(topology.topology.edges).toHaveLength(2); + + const [firstNode] = topology.topology.nodes as Array>; + expect(firstNode.id).toBe('ingest'); + expect(firstNode.name).toBe('Ingest'); + + const conditional = (topology.topology.edges as Array>)[1]; + expect(conditional.from).toBe('decide'); + expect(conditional.to).toBe('long'); + }); +}); + +describe('display helpers', () => { + it('extracts text from the flat string output', () => { + expect(extractOutputText('plain answer')).toBe('plain answer'); + }); + + it('handles structured and empty output without throwing', () => { + expect(extractOutputText(undefined)).toBe(''); + expect(extractOutputText(null)).toBe(''); + expect(typeof extractOutputText({ answer: 42 })).toBe('string'); + }); + + it('summarises tool calls and tolerates none', () => { + expect(summariseToolCalls([])).toEqual([]); + const summary = summariseToolCalls([ + { id: '1', type: 'function', function: { name: 'calculator', arguments: '{"expression":"1+1"}' } }, + ]); + expect(summary).toHaveLength(1); + expect(String(summary[0])).toContain('calculator'); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 41e8347..e0525d1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -119,7 +119,7 @@ export function createApiClient(options: ApiClientOptions) { export type ApiClient = ReturnType; -/** Extracts a human readable string from an `AgentExecution.Output` value. */ +/** Extracts a human readable string from an `AgentExecution.output` value. */ export function extractOutputText(output: unknown): string { if (typeof output === 'string') return output; if (output == null) return ''; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ac6c219..93b66d6 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -28,6 +28,15 @@ const typeStyles: Record = { tool: 'bg-amber-500/15 text-amber-500 border-amber-500/30', }; +/** + * `AgentConfig.type` is whatever string the Go server put in the config; the + * union is our narrowing, not a guarantee. Indexing the map directly emitted a + * literal `class="… undefined"` for any type the studio does not know about, + * so unknown types get a neutral badge instead. + */ +const typeStyle = (type: string) => + typeStyles[type as AgentType] ?? 'bg-gray-500/15 text-gray-500 border-gray-500/30'; + export const Header: React.FC = () => { const { agents, @@ -98,7 +107,7 @@ export const Header: React.FC = () => { }`} > {selectedAgent && ( - + {selectedAgent.type} )} @@ -204,7 +213,7 @@ const AgentRow: React.FC<{
{agent.name} - + {agent.type}
diff --git a/src/components/layout/__tests__/Header.test.tsx b/src/components/layout/__tests__/Header.test.tsx new file mode 100644 index 0000000..666d2f4 --- /dev/null +++ b/src/components/layout/__tests__/Header.test.tsx @@ -0,0 +1,100 @@ +/** + * Rendering tests for the header: the agent picker and the connection pill. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Header } from '../Header'; +import { useStudioStore } from '../../../store/useStudioStore'; +import { AgentConfig } from '../../../types'; +import { + agentFixture, + emptyTopologyFixture, + healthFixture, + mockServer, + reactAgentFixture, + resetStudioStore, +} from '../../../test-utils/studioTestUtils'; + +const state = () => useStudioStore.getState(); + +beforeEach(() => { + resetStudioStore(); +}); + +it('renders with no agents and no connection', () => { + render(
); + + expect(screen.getByText('GoLangGraph Studio')).toBeInTheDocument(); + expect(screen.getByText('Select agent')).toBeInTheDocument(); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); +}); + +it('shows the selected agent and its type', () => { + useStudioStore.setState({ selectedAgent: agentFixture, connectionStatus: 'connected', isConnected: true }); + + render(
); + + expect(screen.getByText('Studio Agent')).toBeInTheDocument(); + expect(screen.getByText('chat')).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); +}); + +it('lists the agents and selects the one that is clicked', async () => { + const user = userEvent.setup(); + mockServer({ + '/api/v1/graphs/react-agent/topology': { body: { graph_id: 'react-agent', topology: { nodes: [], edges: [] } } }, + '/api/v1/graphs/studio-agent/topology': { body: emptyTopologyFixture }, + '/api/v1/health': { body: healthFixture }, + }); + useStudioStore.setState({ agents: [agentFixture, reactAgentFixture], selectedAgent: agentFixture }); + + render(
); + await user.click(screen.getByText('Studio Agent')); + + // Both agents are listed with their provider/model line. + expect(screen.getAllByText('fake/fake-model')).toHaveLength(2); + await user.click(screen.getByText('React Agent')); + + expect(state().selectedAgent?.id).toBe('react-agent'); + // Selecting an agent rebuilds its graph, which for a react agent is the + // four-node reasoning loop. + await waitFor(() => expect(state().graphNodes.map((n) => n.id)).toEqual(['input', 'reasoning', 'tools', 'response'])); +}); + +it('says when the server has no agents', async () => { + const user = userEvent.setup(); + useStudioStore.setState({ isConnected: true, connectionStatus: 'connected' }); + + render(
); + await user.click(screen.getByText('Select agent')); + + expect(screen.getByText('No agents found on server.')).toBeInTheDocument(); +}); + +// Regression: `AgentConfig.type` is whatever string the Go server put in the +// config, and the style lookup indexed a three-key map directly. An agent of +// any other type rendered `class="… undefined"`. +it('renders an agent whose type the studio does not know about', () => { + const exotic = { ...agentFixture, type: 'multi-agent' } as unknown as AgentConfig; + useStudioStore.setState({ selectedAgent: exotic }); + + render(
); + + const badge = screen.getByText('multi-agent'); + expect(badge).toBeInTheDocument(); + expect(badge.className).not.toContain('undefined'); +}); + +it('offers a retry while the connection has failed', async () => { + const user = userEvent.setup(); + mockServer({ '/api/v1/health': { networkError: 'connection refused' } }); + useStudioStore.setState({ connectionStatus: 'failed' }); + + render(
); + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => expect(state().retryAttempts).toBe(1)); + expect(state().error).toContain('connection refused'); +}); diff --git a/src/components/layout/__tests__/StudioLayout.test.tsx b/src/components/layout/__tests__/StudioLayout.test.tsx new file mode 100644 index 0000000..97346f1 --- /dev/null +++ b/src/components/layout/__tests__/StudioLayout.test.tsx @@ -0,0 +1,102 @@ +/** + * Integration tests for the studio shell: the layout, its view switching and + * the thread sidebar. + */ + +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StudioLayout } from '../StudioLayout'; +import { Sidebar } from '../Sidebar'; +import { useStudioStore } from '../../../store/useStudioStore'; +import { agentFixture, executionFixture, resetStudioStore } from '../../../test-utils/studioTestUtils'; + +const state = () => useStudioStore.getState(); + +beforeEach(() => { + resetStudioStore(); +}); + +describe('StudioLayout', () => { + it('renders the whole shell from a cold, empty store', () => { + render(); + + expect(screen.getByText('GoLangGraph Studio')).toBeInTheDocument(); + expect(screen.getByText('Threads')).toBeInTheDocument(); + // `currentView` defaults to chat. + expect(screen.getByText('Start a conversation')).toBeInTheDocument(); + }); + + it('switches between the three views', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Graph' })); + expect(screen.getByText('No graph available')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Debug' })); + expect(screen.getByText('No execution yet.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Chat' })); + expect(screen.getByText('Start a conversation')).toBeInTheDocument(); + }); + + it('switches views from the keyboard', async () => { + const user = userEvent.setup(); + render(); + + await user.keyboard('{Control>}2{/Control}'); + await waitFor(() => expect(state().currentView).toBe('graph')); + expect(screen.getByText('No graph available')).toBeInTheDocument(); + + await user.keyboard('{Control>}3{/Control}'); + await waitFor(() => expect(state().currentView).toBe('debug')); + }); + + it('shows a populated debug view when an execution is on record', () => { + useStudioStore.setState({ + currentView: 'debug', + selectedAgent: agentFixture, + lastExecution: executionFixture, + }); + + render(); + + expect(screen.getByText('hi there')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); +}); + +describe('Sidebar', () => { + it('renders an empty thread list', () => { + render(); + + expect(screen.getByText('No threads yet')).toBeInTheDocument(); + }); + + it('creates, previews and deletes threads', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /New Thread/i })); + expect(screen.getByText('Thread 1')).toBeInTheDocument(); + expect(screen.getByText('No messages yet')).toBeInTheDocument(); + + // Driven through the store rather than the UI: the sidebar has no way to + // add a message, but it must re-render with the new preview when one lands. + const threadId = state().selectedThread!.id; + act(() => { + state().addMessage(threadId, { + id: 'm1', + role: 'user', + content: 'the last thing said', + timestamp: new Date(), + }); + }); + expect(await screen.findByText('the last thing said')).toBeInTheDocument(); + + await user.click(screen.getByTitle('Delete thread')); + expect(screen.getByText('No threads yet')).toBeInTheDocument(); + expect(state().selectedThread).toBeUndefined(); + }); +}); diff --git a/src/components/setup/ConnectionSetup.tsx b/src/components/setup/ConnectionSetup.tsx index cd16ad5..0218c80 100644 --- a/src/components/setup/ConnectionSetup.tsx +++ b/src/components/setup/ConnectionSetup.tsx @@ -29,6 +29,11 @@ export const ConnectionSetup: React.FC = () => { const { isConnected, error } = useStudioStore.getState(); if (isConnected) { toast.success('Connected to GoLangGraph server'); + // The server can answer /health and still fail to list agents, tools or + // providers. `connect` records that in `error` while staying connected; + // showing it here is the difference between "this server has no agents" + // and "this server's agent endpoint is broken". + if (error) toast.error(error); navigate('/studio'); } else { toast.error(error || 'Failed to connect'); diff --git a/src/components/views/DebugView.tsx b/src/components/views/DebugView.tsx index 1aada50..19e1da2 100644 --- a/src/components/views/DebugView.tsx +++ b/src/components/views/DebugView.tsx @@ -30,7 +30,7 @@ export const DebugView: React.FC = () => { [executionLogs, filter], ); - const tools = summariseToolCalls(lastExecution?.ToolCalls ?? []); + const tools = summariseToolCalls(lastExecution?.tool_calls ?? []); return (
@@ -51,19 +51,39 @@ export const DebugView: React.FC = () => { {/* Status card */}
- {lastExecution.Success ? ( + {lastExecution.success ? ( ) : ( )} - {lastExecution.Status ?? (lastExecution.Success ? 'Completed' : 'Failed')} + {lastExecution.success ? 'Completed' : 'Failed'}
+ + {/* + A failed execution carries its reason in `error`. Nothing + rendered it, so a failed run showed the word "Failed", an + empty output block and no explanation whatsoever — the one + thing a debugging console has to tell you. + */} + {!lastExecution.success && ( +

+ {lastExecution.error?.trim() || 'The server reported a failure without a reason.'} +

+ )} +
- } label="Duration" value={formatDuration(lastExecution.Duration)} darkMode={darkMode} /> + } label="Duration" value={formatDuration(lastExecution.duration)} darkMode={darkMode} /> } label="Tool calls" value={String(tools.length)} darkMode={darkMode} /> - +
@@ -90,12 +110,12 @@ export const DebugView: React.FC = () => {

Output

- +
-                  {extractOutputText(lastExecution.Output) || '—'}
+                  {extractOutputText(lastExecution.output) || '—'}
                 
diff --git a/src/components/views/__tests__/ChatView.test.tsx b/src/components/views/__tests__/ChatView.test.tsx new file mode 100644 index 0000000..0ea5257 --- /dev/null +++ b/src/components/views/__tests__/ChatView.test.tsx @@ -0,0 +1,166 @@ +/** + * Rendering and interaction tests for the chat view. + * + * The send path goes through the real store and the real API client, so these + * assert on what ends up on screen after a round trip rather than on whether + * `sendMessage` was called. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChatView } from '../ChatView'; +import { useStudioStore } from '../../../store/useStudioStore'; +import { Message } from '../../../types'; +import { + RouteHandler, + agentFixture, + emptyTopologyFixture, + executionFixture, + healthFixture, + mockServer, + resetStudioStore, +} from '../../../test-utils/studioTestUtils'; + +const state = () => useStudioStore.getState(); + +async function connectStudio(overrides: Record = {}) { + mockServer({ + '/api/v1/health': { body: healthFixture }, + '/api/v1/agents': { body: { agents: [agentFixture] } }, + '/api/v1/tools': { body: { tools: [] } }, + '/api/v1/providers': { body: { providers: [] } }, + '/api/v1/graphs/studio-agent/topology': { body: emptyTopologyFixture }, + ...overrides, + }); + await state().connect(); + await waitFor(() => expect(state().selectedAgent).toBeDefined()); +} + +const message = (role: Message['role'], content: string): Message => ({ + id: `${role}-${content}`, + role, + content, + timestamp: new Date('2024-01-01T00:00:00Z'), +}); + +beforeEach(() => { + resetStudioStore(); +}); + +describe('empty state', () => { + it('renders without an agent or a thread', () => { + render(); + + expect(screen.getByText('Start a conversation')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Select an agent to begin')).toBeDisabled(); + }); + + it('describes the selected agent once one is chosen', () => { + useStudioStore.setState({ selectedAgent: agentFixture }); + + render(); + + expect(screen.getByText('Chat with Studio Agent')).toBeInTheDocument(); + expect(screen.getByText('chat agent · fake/fake-model')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Message Studio Agent…')).toBeEnabled(); + }); +}); + +describe('with messages', () => { + it('renders the conversation, markdown and all', () => { + useStudioStore.setState({ + selectedAgent: agentFixture, + selectedThread: { + id: 't1', + name: 'Thread 1', + createdAt: new Date(), + updatedAt: new Date(), + messages: [message('user', 'what is 1+1?'), message('assistant', 'It is **2**.')], + }, + }); + + render(); + + expect(screen.getByText('what is 1+1?')).toBeInTheDocument(); + // The assistant bubble goes through react-markdown, so the bold run is a + // real in the DOM rather than literal asterisks. + expect(screen.getByText('2').tagName).toBe('STRONG'); + }); + + it('shows the typing indicator while an execution is running', () => { + useStudioStore.setState({ selectedAgent: agentFixture, isExecuting: true }); + + render(); + + expect(screen.getByText('Studio Agent is thinking…')).toBeInTheDocument(); + expect(screen.getByTitle('Stop')).toBeInTheDocument(); + }); +}); + +describe('sending a message', () => { + it('puts the agent answer on screen', async () => { + const user = userEvent.setup(); + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } }, + }); + + render(); + await user.type(screen.getByPlaceholderText('Message Studio Agent…'), 'hello'); + await user.click(screen.getByTitle('Send')); + + expect(await screen.findByText('hello')).toBeInTheDocument(); + expect(await screen.findByText('hi there')).toBeInTheDocument(); + }); + + // Regression: the reason a run failed never reached the user. The server + // sends it in `execution.error`, but the bubble said "No output returned." + it('shows the reason a failed run failed instead of "No output returned."', async () => { + const user = userEvent.setup(); + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { + body: { + execution: { ...executionFixture, success: false, output: '', error: 'model backend is offline' }, + }, + }, + }); + + render(); + await user.type(screen.getByPlaceholderText('Message Studio Agent…'), 'hello'); + await user.keyboard('{Enter}'); + + expect(await screen.findByText(/model backend is offline/)).toBeInTheDocument(); + expect(screen.queryByText('No output returned.')).toBeNull(); + }); + + it('reports an unreachable server in the thread', async () => { + const user = userEvent.setup(); + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { networkError: 'socket hang up' }, + }); + + render(); + await user.type(screen.getByPlaceholderText('Message Studio Agent…'), 'hello'); + await user.keyboard('{Enter}'); + + expect(await screen.findByText(/socket hang up/)).toBeInTheDocument(); + }); + + it('clears the composer and keeps Send disabled while it is empty', async () => { + const user = userEvent.setup(); + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } }, + }); + + render(); + const composer = screen.getByPlaceholderText('Message Studio Agent…'); + expect(screen.getByTitle('Send')).toBeDisabled(); + + await user.type(composer, 'hello'); + expect(screen.getByTitle('Send')).toBeEnabled(); + + await user.keyboard('{Enter}'); + + await waitFor(() => expect(composer).toHaveValue('')); + }); +}); diff --git a/src/components/views/__tests__/DebugView.test.tsx b/src/components/views/__tests__/DebugView.test.tsx new file mode 100644 index 0000000..8af357a --- /dev/null +++ b/src/components/views/__tests__/DebugView.test.tsx @@ -0,0 +1,138 @@ +/** + * Rendering tests for the debug view. + * + * This is the panel the studio exists for, so the tests care most about it + * telling the truth: a successful run must show its output, and a failed run + * must show why it failed. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DebugView } from '../DebugView'; +import { useStudioStore } from '../../../store/useStudioStore'; +import { + agentFixture, + emptyTopologyFixture, + executionFixture, + healthFixture, + mockServer, + resetStudioStore, +} from '../../../test-utils/studioTestUtils'; + +const state = () => useStudioStore.getState(); + +/** Connects and runs one agent execution, so the view sees real store output. */ +async function runExecution(execution: Record) { + mockServer({ + '/api/v1/health': { body: healthFixture }, + '/api/v1/agents': { body: { agents: [agentFixture] } }, + '/api/v1/tools': { body: { tools: [] } }, + '/api/v1/providers': { body: { providers: [] } }, + '/api/v1/graphs/studio-agent/topology': { body: emptyTopologyFixture }, + '/api/v1/agents/studio-agent/execute': { body: { execution } }, + }); + await state().connect(); + await waitFor(() => expect(state().graphNodes.length).toBeGreaterThan(0)); + await state().sendMessage('hello'); +} + +beforeEach(() => { + resetStudioStore(); +}); + +it('renders an empty state before anything has run', () => { + render(); + + expect(screen.getByText('No execution yet.')).toBeInTheDocument(); + expect(screen.getByText(/No logs\./)).toBeInTheDocument(); +}); + +it('shows the output, duration and agent of a successful run', async () => { + await runExecution({ ...executionFixture }); + + render(); + + expect(screen.getByText('Completed')).toBeInTheDocument(); + // The execution's output — the whole point of the panel, and blank for every + // run before the snake_case field names were fixed. + expect(screen.getByText('hi there')).toBeInTheDocument(); + // 1_500_000ns formatted from the `duration` the server sends. + expect(screen.getByText('1.5ms')).toBeInTheDocument(); + expect(screen.getByText('Studio Agent')).toBeInTheDocument(); + expect(screen.getByText(/Execution completed in 1\.5ms/)).toBeInTheDocument(); +}); + +// Regression: a failed run rendered the word "Failed", an empty output block +// and nothing else. `AgentExecution.error` holds the reason and no component +// read it, so the debugging console told you nothing about the failure. +it('shows why a failed run failed', async () => { + await runExecution({ ...executionFixture, success: false, output: '', error: 'model backend is offline' }); + + render(); + + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('model backend is offline'); +}); + +it('says so plainly when a failure carries no reason', async () => { + await runExecution({ ...executionFixture, success: false, output: '' }); + + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('The server reported a failure without a reason.'); +}); + +it('does not show a failure banner for a successful run', async () => { + await runExecution({ ...executionFixture }); + + render(); + + expect(screen.queryByRole('alert')).toBeNull(); +}); + +it('lists the tool calls the agent made', async () => { + await runExecution({ + ...executionFixture, + tool_calls: [ + { id: '1', type: 'function', function: { name: 'calculator', arguments: '{"expression":"1+1"}' } }, + ], + }); + + render(); + + expect(screen.getByText('Tool Calls')).toBeInTheDocument(); + expect(screen.getAllByText('calculator({"expression":"1+1"})').length).toBeGreaterThan(0); +}); + +it('filters the log by level', async () => { + const user = userEvent.setup(); + await runExecution({ + ...executionFixture, + tool_calls: [{ id: '1', type: 'function', function: { name: 'calculator', arguments: '{}' } }], + }); + + render(); + expect(screen.getByText(/Executing agent "Studio Agent"/)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'debug' })); + + // Only the tool-call line survives the filter; the info lines are gone. + expect(screen.queryByText(/Executing agent "Studio Agent"/)).toBeNull(); + expect(screen.getByText('Tool call: calculator({})')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'warn' })); + expect(screen.getByText('No logs at warn level.')).toBeInTheDocument(); +}); + +it('renders an execution whose output is a structured object', async () => { + // The server's legacy flat `output` is a string, but a structured payload + // must not blow up the panel either. + useStudioStore.setState({ + lastExecution: { ...executionFixture, output: { answer: 42 } as unknown as string }, + }); + + render(); + + expect(screen.getByText(/"answer": 42/)).toBeInTheDocument(); +}); diff --git a/src/components/views/__tests__/GraphView.test.tsx b/src/components/views/__tests__/GraphView.test.tsx new file mode 100644 index 0000000..2897830 --- /dev/null +++ b/src/components/views/__tests__/GraphView.test.tsx @@ -0,0 +1,131 @@ +/** + * Rendering tests for the execution graph. + * + * The graph is laid out by hand from node positions, so most of what can go + * wrong here is arithmetic over data that is missing or empty. + * + * The edges are SVG primitives: they carry no accessible role, no text + * and no label, so there is no Testing Library query that can reach them and + * the rendered container is the only way to assert on the drawn geometry. + */ +/* eslint-disable testing-library/no-container, testing-library/no-node-access */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { GraphView } from '../GraphView'; +import { useStudioStore } from '../../../store/useStudioStore'; +import { GraphEdge, GraphNode } from '../../../types'; +import { + agentFixture, + emptyTopologyFixture, + healthFixture, + mockServer, + resetStudioStore, +} from '../../../test-utils/studioTestUtils'; + +const nodes: GraphNode[] = [ + { id: 'input', label: 'Input', type: 'input', status: 'completed', position: { x: 80, y: 80 }, description: 'User message' }, + { id: 'agent', label: 'Studio Agent', type: 'llm', status: 'error', position: { x: 80, y: 210 } }, + { id: 'output', label: 'Output', type: 'output', status: 'idle', position: { x: 80, y: 340 } }, +]; + +const edges: GraphEdge[] = [ + { id: 'input-agent', source: 'input', target: 'agent' }, + { id: 'agent-output', source: 'agent', target: 'output', label: 'respond' }, +]; + +beforeEach(() => { + resetStudioStore(); +}); + +describe('with no graph', () => { + it('renders an empty state instead of laying out zero nodes', () => { + const { container } = render(); + + expect(screen.getByText('No graph available')).toBeInTheDocument(); + expect(screen.getByText(/Select an agent to visualise/i)).toBeInTheDocument(); + // The empty-state early return is what keeps the layout maths away from an + // empty array: `Math.min(...[])` is `Infinity`, which would end up in the + // canvas width/height as `-Infinity` and in every SVG coordinate. + expect(container.querySelector('svg')).toBeNull(); + }); + + it('names the agent whose topology came back empty', () => { + useStudioStore.setState({ selectedAgent: agentFixture }); + + render(); + + expect(screen.getByText('No topology was returned for "Studio Agent".')).toBeInTheDocument(); + }); +}); + +describe('with a graph', () => { + it('renders every node with its label, type and status', () => { + useStudioStore.setState({ selectedAgent: agentFixture, graphNodes: nodes, graphEdges: edges }); + + render(); + + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Output')).toBeInTheDocument(); + expect(screen.getByText('User message')).toBeInTheDocument(); + // Twice: once as the legend's agent name, once as the LLM node's label. + expect(screen.getAllByText('Studio Agent')).toHaveLength(2); + + // One status dot per node, titled with its state. + expect(screen.getAllByTitle('Completed')).toHaveLength(1); + expect(screen.getAllByTitle('Error')).toHaveLength(1); + expect(screen.getAllByTitle('Idle')).toHaveLength(1); + }); + + it('draws one line per edge and labels the conditional ones', () => { + useStudioStore.setState({ graphNodes: nodes, graphEdges: edges }); + + const { container } = render(); + + expect(container.querySelectorAll('line')).toHaveLength(2); + expect(screen.getByText('respond')).toBeInTheDocument(); + }); + + it('skips an edge whose endpoints are not in the graph', () => { + useStudioStore.setState({ + graphNodes: nodes, + graphEdges: [...edges, { id: 'dangling', source: 'input', target: 'nowhere' }], + }); + + const { container } = render(); + + // The dangling edge is dropped rather than drawn to `undefined` coordinates. + expect(container.querySelectorAll('line')).toHaveLength(2); + }); + + it('gives every node a finite position on the canvas', () => { + useStudioStore.setState({ graphNodes: nodes, graphEdges: edges }); + + const { container } = render(); + + container.querySelectorAll('line').forEach((line) => { + ['x1', 'y1', 'x2', 'y2'].forEach((attr) => { + expect(Number(line.getAttribute(attr))).not.toBeNaN(); + }); + }); + }); + + it('renders the graph the store derived from a real connection', async () => { + mockServer({ + '/api/v1/health': { body: healthFixture }, + '/api/v1/agents': { body: { agents: [agentFixture] } }, + '/api/v1/tools': { body: { tools: [] } }, + '/api/v1/providers': { body: { providers: [] } }, + '/api/v1/graphs/studio-agent/topology': { body: emptyTopologyFixture }, + }); + await useStudioStore.getState().connect(); + await waitFor(() => expect(useStudioStore.getState().graphNodes).toHaveLength(3)); + + render(); + + // The chat agent's derived graph: Input → → Output. + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getAllByText('Studio Agent').length).toBeGreaterThan(0); + expect(screen.getByText('Output')).toBeInTheDocument(); + }); +}); diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..74e46be --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,16 @@ +// Jest setup, picked up automatically by react-scripts. +// +// `@testing-library/jest-dom` adds the DOM matchers (`toBeInTheDocument`, +// `toHaveTextContent`, …) the component tests use to assert against real +// rendered output rather than against mocks. +import '@testing-library/jest-dom'; + +// jsdom implements no layout, so `Element.prototype.scrollIntoView` is simply +// missing. ChatView calls it from an effect to keep the newest message in +// view; without this stub every ChatView render throws in tests even though +// the code is correct in every real browser. +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = function scrollIntoView() { + /* no-op: nothing to scroll in jsdom */ + }; +} diff --git a/src/store/__tests__/useStudioStore.test.ts b/src/store/__tests__/useStudioStore.test.ts new file mode 100644 index 0000000..1c540fd --- /dev/null +++ b/src/store/__tests__/useStudioStore.test.ts @@ -0,0 +1,490 @@ +/** + * Behavioural tests for the studio store. + * + * The store is the only thing between the Go server's JSON and everything the + * user sees, so these tests drive the real store through the real API client + * with only `fetch` stubbed, and assert on the resulting state — the messages + * in the thread, the logs, the node statuses — rather than on mock calls. + */ + +import { waitFor } from '@testing-library/react'; +import { useStudioStore } from '../useStudioStore'; +import { + RouteHandler, + agentFixture, + deferred, + emptyTopologyFixture, + executionFixture, + healthFixture, + mockServer, + reactAgentFixture, + resetStudioStore, +} from '../../test-utils/studioTestUtils'; + +const state = () => useStudioStore.getState(); + +/** Routes a healthy single-agent server answers, before per-test overrides. */ +const baseRoutes = (): Record => ({ + '/api/v1/health': { body: healthFixture }, + '/api/v1/agents': { body: { agents: [agentFixture] } }, + '/api/v1/tools': { body: { tools: ['calculator', 'time'] } }, + '/api/v1/providers': { + body: { providers: [{ name: 'ollama', type: 'ollama', endpoint: 'http://localhost:11434', model: 'gemma3:1b' }] }, + }, + '/api/v1/graphs/studio-agent/topology': { body: emptyTopologyFixture }, + '/api/v1/graphs/react-agent/topology': { body: { graph_id: 'react-agent', topology: { nodes: [], edges: [] } } }, +}); + +/** Connects to a stubbed server and waits for the initial graph to settle. */ +async function connectStudio(overrides: Record = {}) { + mockServer({ ...baseRoutes(), ...overrides }); + await state().connect(); + await waitFor(() => expect(state().graphNodes.length).toBeGreaterThan(0)); +} + +beforeEach(() => { + resetStudioStore(); +}); + +describe('connecting', () => { + it('loads the catalogue and selects the first agent', async () => { + await connectStudio(); + + expect(state().isConnected).toBe(true); + expect(state().isConnecting).toBe(false); + expect(state().connectionStatus).toBe('connected'); + expect(state().error).toBeUndefined(); + expect(state().agents.map((a) => a.name)).toEqual(['Studio Agent']); + expect(state().tools).toEqual(['calculator', 'time']); + expect(state().providers.map((p) => p.name)).toEqual(['ollama']); + expect(state().selectedAgent?.id).toBe('studio-agent'); + }); + + it('surfaces a connection failure to the user instead of hanging', async () => { + mockServer({ '/api/v1/health': { networkError: 'connection refused' } }); + + await state().connect(); + + expect(state().isConnected).toBe(false); + expect(state().connectionStatus).toBe('failed'); + // The loading flag must be cleared on the error path too, or the setup + // screen's Connect button stays disabled forever. + expect(state().isConnecting).toBe(false); + expect(state().error).toContain('connection refused'); + }); + + it('reports the server error body when the health check fails', async () => { + mockServer({ '/api/v1/health': { status: 503, body: { error: 'server is starting up' } } }); + + await state().connect(); + + expect(state().connectionStatus).toBe('failed'); + expect(state().error).toBe('server is starting up'); + }); + + // Regression: the three catalogue calls were `.catch(() => [])`, so a server + // whose /agents endpoint was broken looked exactly like a server with no + // agents configured — "Connected", empty list, not a word about the failure. + it('reports a catalogue endpoint that fails even though the server is up', async () => { + mockServer({ + ...baseRoutes(), + '/api/v1/agents': { status: 500, body: { error: 'agent registry unavailable' } }, + }); + + await state().connect(); + + expect(state().isConnected).toBe(true); + expect(state().agents).toEqual([]); + expect(state().error).toContain('agents'); + expect(state().error).toContain('agent registry unavailable'); + expect(state().executionLogs.map((l) => `${l.level}: ${l.message}`)).toContainEqual( + expect.stringContaining('agent registry unavailable'), + ); + // Tools and providers still loaded, so a single broken endpoint does not + // cost us the rest of the catalogue. + expect(state().tools).toEqual(['calculator', 'time']); + }); + + it('counts retries and explains itself once the budget is spent', async () => { + mockServer({ '/api/v1/health': { networkError: 'connection refused' } }); + + await state().attemptReconnection(); + expect(state().retryAttempts).toBe(1); + expect(state().connectionStatus).toBe('failed'); + + useStudioStore.setState({ retryAttempts: state().maxRetryAttempts }); + await state().attemptReconnection(); + + // Regression: this path used to set `connectionStatus: 'failed'` and + // return, so the Retry button did nothing at all with no explanation. + expect(state().error).toContain('Gave up after 3 connection attempts'); + }); + + it('surfaces a failed agent reload', async () => { + await connectStudio(); + mockServer({ ...baseRoutes(), '/api/v1/agents': { status: 500, body: { error: 'registry exploded' } } }); + + await state().loadAgents(); + + expect(state().error).toBe('registry exploded'); + }); + + // Regression: disconnect left `lastExecution` and `currentGraphNode` behind, + // so the debug view kept describing a run whose logs had just been wiped and + // the graph kept a highlight pointing at a node that no longer existed. + it('clears every trace of the old server on disconnect', async () => { + await connectStudio({ '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } } }); + await state().sendMessage('hello'); + expect(state().lastExecution).toBeDefined(); + + state().disconnect(); + + expect(state().isConnected).toBe(false); + expect(state().connectionStatus).toBe('disconnected'); + expect(state().lastExecution).toBeUndefined(); + expect(state().currentGraphNode).toBeUndefined(); + expect(state().isExecuting).toBe(false); + expect(state().retryAttempts).toBe(0); + expect(state().error).toBeUndefined(); + expect(state().graphNodes).toEqual([]); + expect(state().selectedAgent).toBeUndefined(); + }); +}); + +describe('graph data', () => { + // The `/graphs/{id}/topology` endpoint is a placeholder that answers with an + // empty graph. The store is supposed to fall back to a graph derived from + // the agent's type; this pins that it actually does. + it('falls back to the type-derived graph when the topology is empty', async () => { + await connectStudio(); + + expect(state().graphNodes.map((n) => n.label)).toEqual(['Input', 'Studio Agent', 'Output']); + expect(state().graphNodes.map((n) => n.status)).toEqual(['idle', 'idle', 'idle']); + expect(state().graphEdges.map((e) => `${e.source}->${e.target}`)).toEqual([ + 'input->agent', + 'agent->output', + ]); + }); + + it('derives a different graph for a react agent', async () => { + await connectStudio({ '/api/v1/agents': { body: { agents: [reactAgentFixture] } } }); + + expect(state().graphNodes.map((n) => n.label)).toEqual(['Input', 'Reasoning', 'Tools', 'Response']); + expect(state().graphEdges).toHaveLength(4); + }); + + it('falls back when the topology request fails outright', async () => { + await connectStudio({ + '/api/v1/graphs/studio-agent/topology': { status: 500, body: { error: 'not implemented' } }, + }); + + expect(state().graphNodes.map((n) => n.id)).toEqual(['input', 'agent', 'output']); + }); + + it('prefers the real topology when the server returns one', async () => { + await connectStudio({ + '/api/v1/graphs/studio-agent/topology': { + body: { + graph_id: 'studio-agent', + topology: { + nodes: [ + { id: 'ingest', name: 'Ingest', type: 'node', is_start: true }, + { id: 'decide', name: 'Decide', type: 'node' }, + ], + edges: [{ from: 'ingest', to: 'decide', conditional: false }], + }, + }, + }, + }); + + expect(state().graphNodes.map((n) => n.id)).toEqual(['ingest', 'decide']); + expect(state().graphNodes.map((n) => n.label)).toEqual(['Ingest', 'Decide']); + // The server names edge endpoints `from`/`to`, not `source`/`target`. + expect(state().graphEdges).toEqual([ + { id: 'edge-0', source: 'ingest', target: 'decide' }, + ]); + }); + + // Regression: two topology requests raced and whichever came back last won, + // so picking agent A then B could leave B selected while A's graph was on + // screen. + it('ignores a slow topology response for an agent that is no longer selected', async () => { + await connectStudio({ '/api/v1/agents': { body: { agents: [agentFixture, reactAgentFixture] } } }); + + const slowStudio = deferred<{ body: unknown }>(); + mockServer({ + ...baseRoutes(), + '/api/v1/graphs/studio-agent/topology': () => slowStudio.promise, + }); + + state().selectAgent(agentFixture); + state().selectAgent(reactAgentFixture); + + // The react agent has no server topology, so it settles on its derived graph. + await waitFor(() => expect(state().graphNodes).toHaveLength(4)); + expect(state().selectedAgent?.id).toBe('react-agent'); + + slowStudio.resolve({ + body: { + graph_id: 'studio-agent', + topology: { nodes: [{ id: 'stale', name: 'Stale', type: 'node' }], edges: [] }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(state().graphNodes.map((n) => n.id)).toEqual(['input', 'reasoning', 'tools', 'response']); + expect(state().selectedAgent?.id).toBe('react-agent'); + }); + + // Regression: an unknown agent id returned early, leaving the previous + // agent's graph on screen and silently attributing it to the new selection. + it('clears the graph rather than showing a stale one for an unknown agent', async () => { + await connectStudio(); + expect(state().graphNodes).toHaveLength(3); + + await state().fetchGraphData('an-agent-that-does-not-exist'); + + expect(state().graphNodes).toEqual([]); + expect(state().graphEdges).toEqual([]); + }); +}); + +describe('executing an agent', () => { + it('records the answer, the execution and a log line', async () => { + await connectStudio({ '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } } }); + + await state().sendMessage('hello'); + + const messages = state().selectedThread?.messages ?? []; + expect(messages.map((m) => `${m.role}: ${m.content}`)).toEqual([ + 'user: hello', + 'assistant: hi there', + ]); + expect(state().lastExecution?.output).toBe('hi there'); + expect(state().lastExecution?.success).toBe(true); + expect(state().isExecuting).toBe(false); + expect(state().error).toBeUndefined(); + + // 1.5ms in nanoseconds, formatted for the log line. + expect(state().executionLogs.map((l) => l.message)).toContainEqual( + expect.stringContaining('Execution completed in 1.5ms'), + ); + expect(messages[1].metadata).toMatchObject({ status: 'completed', duration_ns: 1500000 }); + }); + + it('creates a thread when none is selected yet', async () => { + await connectStudio({ '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } } }); + expect(state().selectedThread).toBeUndefined(); + + await state().sendMessage('hello'); + + expect(state().threads).toHaveLength(1); + expect(state().threads[0].messages).toHaveLength(2); + }); + + it('refuses to run without an agent and says so', async () => { + await state().sendMessage('hello'); + + expect(state().error).toBe('No agent selected'); + expect(state().threads).toEqual([]); + }); + + it('logs each tool call the agent made', async () => { + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { + body: { + execution: { + ...executionFixture, + tool_calls: [ + { id: '1', type: 'function', function: { name: 'calculator', arguments: '{"expression":"1+1"}' } }, + ], + }, + }, + }, + }); + + await state().sendMessage('what is 1+1?'); + + expect(state().executionLogs.filter((l) => l.level === 'debug').map((l) => l.message)).toEqual([ + 'Tool call: calculator({"expression":"1+1"})', + ]); + }); + + // Regression: `execution.error` was never read. A failed run logged only + // "failed", and because a failed run usually has no output the assistant + // bubble said "No output returned." — the reason never reached the user. + it('surfaces the reason a failed execution failed', async () => { + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { + body: { + execution: { + ...executionFixture, + success: false, + output: '', + error: 'model backend is offline', + }, + }, + }, + }); + + await state().sendMessage('hello'); + + const messages = state().selectedThread?.messages ?? []; + expect(messages[1].content).toContain('model backend is offline'); + expect(messages[1].metadata).toMatchObject({ status: 'failed', error: 'model backend is offline' }); + expect(state().error).toBe('model backend is offline'); + expect(state().executionLogs.map((l) => `${l.level}|${l.message}`)).toContainEqual( + expect.stringContaining('error|Execution failed in 1.5ms'), + ); + expect(state().executionLogs.map((l) => l.message)).toContainEqual( + expect.stringContaining('model backend is offline'), + ); + }); + + it('still says something useful when a failure carries no reason', async () => { + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { + body: { execution: { ...executionFixture, success: false, output: '' } }, + }, + }); + + await state().sendMessage('hello'); + + const messages = state().selectedThread?.messages ?? []; + expect(messages[1].content).toContain('Execution failed'); + expect(state().error).toBe('The execution failed without a reported reason.'); + }); + + // Regression: every node was marked `completed` unconditionally, so a failed + // run painted the whole graph green. The `error` status existed in the type + // but nothing ever produced it. + it('marks the graph as failed, not completed, when the run fails', async () => { + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { + body: { execution: { ...executionFixture, success: false, error: 'boom' } }, + }, + }); + + await state().sendMessage('hello'); + + expect(state().graphNodes.map((n) => n.status)).toEqual(['error', 'error', 'error']); + }); + + it('marks the graph as completed when the run succeeds', async () => { + await connectStudio({ '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } } }); + + await state().sendMessage('hello'); + + expect(state().graphNodes.map((n) => n.status)).toEqual(['completed', 'completed', 'completed']); + // Regression: `currentGraphNode` was assigned and then immediately wiped + // by the `finally` block, so the highlight showing where the run ended was + // dead code and the graph never highlighted anything after a run. + expect(state().currentGraphNode).toBe('output'); + }); + + it('follows the server execution path when the graph ids line up', async () => { + await connectStudio({ + '/api/v1/graphs/studio-agent/topology': { + body: { + graph_id: 'studio-agent', + topology: { + nodes: [ + { id: 'ingest', name: 'Ingest', type: 'node' }, + { id: 'decide', name: 'Decide', type: 'node' }, + { id: 'finish', name: 'Finish', type: 'node' }, + ], + edges: [ + { from: 'ingest', to: 'decide' }, + { from: 'decide', to: 'finish' }, + ], + }, + }, + }, + '/api/v1/agents/studio-agent/execute': { + body: { execution: { ...executionFixture, execution_path: ['ingest', 'decide'] } }, + }, + }); + + await state().sendMessage('hello'); + + // `finish` never ran, so it must not be reported as completed. + expect(state().graphNodes.map((n) => `${n.id}:${n.status}`)).toEqual([ + 'ingest:completed', + 'decide:completed', + 'finish:idle', + ]); + expect(state().currentGraphNode).toBe('decide'); + }); + + it('puts a transport failure in the thread and clears the running flag', async () => { + await connectStudio({ + '/api/v1/agents/studio-agent/execute': { networkError: 'socket hang up' }, + }); + + await state().sendMessage('hello'); + + const messages = state().selectedThread?.messages ?? []; + expect(messages[1].content).toContain('socket hang up'); + expect(state().error).toContain('socket hang up'); + expect(state().isExecuting).toBe(false); + expect(state().currentGraphNode).toBeUndefined(); + }); + + // Regression: Stop only flipped `isExecuting`. The request kept running and + // its answer was appended to the thread seconds after the user stopped it. + it('drops the result of an execution the user stopped', async () => { + const slowRun = deferred<{ body: unknown }>(); + await connectStudio({ '/api/v1/agents/studio-agent/execute': () => slowRun.promise }); + + const pending = state().sendMessage('hello'); + expect(state().isExecuting).toBe(true); + + state().stopExecution(); + expect(state().isExecuting).toBe(false); + + slowRun.resolve({ body: { execution: executionFixture } }); + await pending; + + expect(state().selectedThread?.messages.map((m) => m.role)).toEqual(['user']); + expect(state().lastExecution).toBeUndefined(); + expect(state().executionLogs.map((l) => l.message)).toContainEqual( + expect.stringContaining('Discarded the result of a stopped execution.'), + ); + }); + + it('clears a previous error when a new run starts', async () => { + await connectStudio({ '/api/v1/agents/studio-agent/execute': { body: { execution: executionFixture } } }); + useStudioStore.setState({ error: 'a stale failure from the last run' }); + + await state().sendMessage('hello'); + + expect(state().error).toBeUndefined(); + }); +}); + +describe('threads', () => { + it('keeps the selected thread in step with the thread list', () => { + state().createThread(); + const threadId = state().selectedThread!.id; + + state().addMessage(threadId, { + id: 'm1', + role: 'user', + content: 'hello', + timestamp: new Date(), + }); + + expect(state().selectedThread?.messages).toHaveLength(1); + expect(state().threads[0].messages).toHaveLength(1); + }); + + it('deselects a thread that is deleted', () => { + state().createThread(); + const threadId = state().selectedThread!.id; + + state().deleteThread(threadId); + + expect(state().threads).toEqual([]); + expect(state().selectedThread).toBeUndefined(); + }); +}); diff --git a/src/store/useStudioStore.ts b/src/store/useStudioStore.ts index 80fa1a1..843229d 100644 --- a/src/store/useStudioStore.ts +++ b/src/store/useStudioStore.ts @@ -19,6 +19,10 @@ import { ApiClient, createApiClient, extractOutputText, summariseToolCalls } fro const uid = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +/** Normalises anything thrown into a message we can put in front of the user. */ +const describeError = (error: unknown) => + error instanceof Error ? error.message : String(error); + // --------------------------------------------------------------------------- // Graph construction // @@ -144,8 +148,25 @@ interface StudioStore { const clientRef: { current: ApiClient | null } = { current: null }; +/** + * Monotonic tokens used to discard the results of superseded async work. + * + * Graph fetches and agent executions are promises that write into shared state + * whenever they happen to resolve. Without a token, selecting agent A and then + * agent B leaves B's graph overwritten by A's slower topology response, and + * pressing Stop still appends the abandoned answer to the thread once the + * in-flight request lands. Each async entry point takes a token on the way in + * and only writes state back if it is still the current one. + */ +const graphRequestRef = { current: 0 }; +const executionRef = { current: 0 }; + function resetClient() { clientRef.current = null; + // Anything already in flight belongs to the previous server/config, so its + // result must never be applied. + graphRequestRef.current += 1; + executionRef.current += 1; } export const useStudioStore = create()( @@ -174,10 +195,26 @@ export const useStudioStore = create()( await client.health(); + // Only the health probe decides whether we are connected; the three + // catalogue calls are allowed to fail independently. They must not + // fail *silently* though: these were previously `.catch(() => [])`, + // so a server whose /agents endpoint was broken was indistinguishable + // from one with no agents configured — the studio just showed an + // empty agent list and said "Connected". + const failures: string[] = []; + const loadOrReport = async (label: string, load: () => Promise, fallback: T): Promise => { + try { + return await load(); + } catch (error) { + failures.push(`${label} (${describeError(error)})`); + return fallback; + } + }; + const [agents, tools, providers] = await Promise.all([ - client.listAgents().catch(() => [] as AgentConfig[]), - client.listTools().catch(() => [] as string[]), - client.listProviders().catch(() => [] as ProviderInfo[]), + loadOrReport('agents', () => client.listAgents(), [] as AgentConfig[]), + loadOrReport('tools', () => client.listTools(), [] as string[]), + loadOrReport('providers', () => client.listProviders(), [] as ProviderInfo[]), ]); set({ @@ -185,12 +222,16 @@ export const useStudioStore = create()( isConnecting: false, connectionStatus: 'connected', retryAttempts: 0, - error: undefined, + error: failures.length > 0 ? `Connected, but could not load ${failures.join(', ')}` : undefined, agents, tools, providers, }); + failures.forEach((failure) => + get().addExecutionLog({ level: 'warn', message: `Could not load ${failure}` }), + ); + if (agents.length > 0) { get().selectAgent(agents[0]); } @@ -199,7 +240,7 @@ export const useStudioStore = create()( isConnected: false, isConnecting: false, connectionStatus: 'failed', - error: error instanceof Error ? error.message : String(error), + error: describeError(error), }); } }, @@ -208,21 +249,37 @@ export const useStudioStore = create()( resetClient(); set({ isConnected: false, + // Everything below describes the server we are walking away from. + // Leaving `lastExecution` and `currentGraphNode` behind made the + // debug view describe a run whose logs had just been cleared and the + // graph highlight point at a node that no longer existed, and a + // stuck `isExecuting` left the composer showing a Stop button. + isConnecting: false, + isExecuting: false, connectionStatus: 'disconnected', + retryAttempts: 0, + error: undefined, agents: [], selectedAgent: undefined, tools: [], providers: [], executionLogs: [], + lastExecution: undefined, graphNodes: [], graphEdges: [], + currentGraphNode: undefined, }); }, attemptReconnection: async () => { const { retryAttempts, maxRetryAttempts } = get(); if (retryAttempts >= maxRetryAttempts) { - set({ connectionStatus: 'failed' }); + // The Retry button stays on screen once the budget is spent, so say + // why pressing it does nothing rather than failing silently. + set({ + connectionStatus: 'failed', + error: `Gave up after ${maxRetryAttempts} connection attempts. Check the server URL and that the GoLangGraph server is running, then reconnect from the setup screen.`, + }); return; } set({ retryAttempts: retryAttempts + 1, connectionStatus: 'connecting' }); @@ -236,23 +293,48 @@ export const useStudioStore = create()( const agents = await clientRef.current.listAgents(); set({ agents }); } catch (error) { - set({ error: error instanceof Error ? error.message : String(error) }); + set({ error: describeError(error) }); } }, selectAgent: (agent) => { - set({ selectedAgent: agent }); - if (agent) get().fetchGraphData(agent.id).catch(() => undefined); + // Clear the highlight up front: it names a node in the outgoing + // agent's graph and means nothing in the incoming one. + set({ selectedAgent: agent, currentGraphNode: undefined }); + if (!agent) { + set({ graphNodes: [], graphEdges: [] }); + return; + } + // Fire and forget, but report rather than discard: this used to be + // `.catch(() => undefined)`, which hid any graph failure completely. + get() + .fetchGraphData(agent.id) + .catch((error) => + get().addExecutionLog({ + level: 'warn', + message: `Could not build the graph for "${agent.name}": ${describeError(error)}`, + }), + ); }, fetchGraphData: async (agentId) => { const agent = get().agents.find((a) => a.id === agentId); - if (!agent) return; + if (!agent) { + // Nothing is known about this agent, so there is no graph to draw. + // Returning early used to leave the *previous* agent's graph on + // screen, silently attributing it to the new selection. + set({ graphNodes: [], graphEdges: [], currentGraphNode: undefined }); + return; + } + + const token = ++graphRequestRef.current; + const isCurrent = () => graphRequestRef.current === token; // Prefer the real topology when the server provides one. try { if (clientRef.current) { const topology = await clientRef.current.getGraphTopology(agentId); + if (!isCurrent()) return; const rawNodes = topology?.topology?.nodes ?? []; const rawEdges = topology?.topology?.edges ?? []; if (rawNodes.length > 0 || rawEdges.length > 0) { @@ -269,6 +351,7 @@ export const useStudioStore = create()( source: String(e.from ?? e.source), target: String(e.to ?? e.target), })), + currentGraphNode: undefined, }); return; } @@ -277,6 +360,7 @@ export const useStudioStore = create()( // Fall through to the type-derived graph. } + if (!isCurrent()) return; const { nodes, edges } = buildTypeGraph(agent); set({ graphNodes: nodes, graphEdges: edges, currentGraphNode: undefined }); }, @@ -345,7 +429,15 @@ export const useStudioStore = create()( clearLogs: () => set({ executionLogs: [], lastExecution: undefined }), - stopExecution: () => set({ isExecuting: false }), + stopExecution: () => { + // The API client exposes no cancellation, so the HTTP request itself + // runs to completion. Bumping the token makes `sendMessage` drop + // whatever comes back: without it, pressing Stop cleared the spinner + // but the abandoned answer still appeared in the thread seconds later. + executionRef.current += 1; + set({ isExecuting: false, currentGraphNode: undefined }); + get().addExecutionLog({ level: 'warn', message: 'Execution stopped.' }); + }, sendMessage: async (content) => { const { config, selectedAgent, selectedThread } = get(); @@ -370,7 +462,10 @@ export const useStudioStore = create()( }; get().addMessage(thread.id, userMessage); - set({ isExecuting: true, currentGraphNode: 'input' }); + const token = ++executionRef.current; + // `error` describes the most recent failure only — a new run must not + // be read against the previous run's message. + set({ isExecuting: true, currentGraphNode: 'input', error: undefined }); get().addExecutionLog({ level: 'info', message: `Executing agent "${selectedAgent.name}"` }); const markNode = (nodeId: string, status: GraphNode['status']) => @@ -389,41 +484,86 @@ export const useStudioStore = create()( const execution = await client.executeAgent(selectedAgent.id, content); const wallMs = Date.now() - started; + // Superseded by Stop or by a newer run while we were waiting. + if (executionRef.current !== token) { + get().addExecutionLog({ + level: 'warn', + message: 'Discarded the result of a stopped execution.', + }); + return; + } + set({ lastExecution: execution }); + + // A failed execution carries its reason in `error`. It was never + // read, so the log line said only "failed" and the assistant bubble + // said "No output returned." — the user was told nothing at all + // about why the run did not work. + const reason = execution.error?.trim(); + const outcome = execution.success ? 'completed' : 'failed'; get().addExecutionLog({ - level: execution.Success ? 'info' : 'error', - message: `Execution ${execution.Status || (execution.Success ? 'completed' : 'failed')} in ${formatDuration(execution.Duration)} (HTTP ${wallMs}ms)`, + level: execution.success ? 'info' : 'error', + message: + `Execution ${outcome} in ${formatDuration(execution.duration)} (HTTP ${wallMs}ms)` + + (!execution.success && reason ? `: ${reason}` : ''), data: execution, }); - for (const call of summariseToolCalls(execution.ToolCalls)) { + for (const call of summariseToolCalls(execution.tool_calls ?? [])) { get().addExecutionLog({ level: 'debug', message: `Tool call: ${call}` }); } - // Reflect node statuses from the execution path when available. - const path = execution.ExecutionPath ?? []; + // Reflect node statuses from the execution. + // + // `execution_path` names the nodes the server actually ran. Those ids + // only line up with the graph when the server returned a real + // topology — the type-derived fallback graph uses its own ids — so + // when none of them match we fall back to marking the whole graph. + // Either way a failed run must not be painted green, which is what + // the previous unconditional `markNode(id, 'completed')` did: the + // `error` node status existed but was never produced. + const path = execution.execution_path ?? []; const nodes = get().graphNodes; - nodes.forEach((node) => markNode(node.id, 'completed')); - if (path.length > 0) { - path.forEach((id) => markNode(id, 'completed')); + const visited = path.filter((id) => nodes.some((n) => n.id === id)); + const finalStatus: GraphNode['status'] = execution.success ? 'completed' : 'error'; + + if (visited.length > 0) { + const lastVisited = visited[visited.length - 1]; + nodes.forEach((node) => + markNode(node.id, visited.indexOf(node.id) >= 0 ? 'completed' : 'idle'), + ); + markNode(lastVisited, finalStatus); + set({ currentGraphNode: lastVisited }); + } else { + nodes.forEach((node) => markNode(node.id, finalStatus)); + set({ currentGraphNode: nodes[nodes.length - 1]?.id }); } - set({ currentGraphNode: path[path.length - 1] ?? nodes[nodes.length - 1]?.id }); - const text = extractOutputText(execution.Output); + const text = extractOutputText(execution.output); const assistantMessage: Message = { id: uid('msg'), role: 'assistant', - content: text || 'No output returned.', + content: + text || + (execution.success + ? 'No output returned.' + : `❌ Execution failed: ${reason || 'the server did not report a reason.'}`), timestamp: new Date(), metadata: { - duration_ns: execution.Duration, - status: execution.Status, - tool_calls: execution.ToolCalls?.length ?? 0, + duration_ns: execution.duration, + status: outcome, + tool_calls: execution.tool_calls?.length ?? 0, + ...(reason ? { error: reason } : {}), }, }; get().addMessage(thread.id, assistantMessage); + + if (!execution.success) { + set({ error: reason || 'The execution failed without a reported reason.' }); + } } catch (error) { - const message = error instanceof Error ? error.message : String(error); + if (executionRef.current !== token) return; + const message = describeError(error); get().addExecutionLog({ level: 'error', message: `Execution failed: ${message}` }); const errorMessage: Message = { id: uid('msg'), @@ -432,9 +572,13 @@ export const useStudioStore = create()( timestamp: new Date(), }; get().addMessage(thread.id, errorMessage); - set({ error: message }); + set({ error: message, currentGraphNode: undefined }); } finally { - set({ isExecuting: false, currentGraphNode: undefined }); + // Only the run that is still current owns these flags, and the + // `currentGraphNode` set above must survive: clearing it here threw + // away the highlight identifying where the run ended, making that + // whole computation dead code. + if (executionRef.current === token) set({ isExecuting: false }); } }, diff --git a/src/test-utils/studioTestUtils.ts b/src/test-utils/studioTestUtils.ts new file mode 100644 index 0000000..85c4b5a --- /dev/null +++ b/src/test-utils/studioTestUtils.ts @@ -0,0 +1,136 @@ +/** + * Shared harness for the studio tests. + * + * The tests drive the real store through the real `ApiClient` with only + * `fetch` replaced, so the code under test is exactly what the app runs and + * the JSON it reads is exactly what the Go server sends. Nothing here asserts + * that a mock was called — the stubs stand in for the network and nothing + * more. + */ +import { AgentConfig, AgentExecution } from '../types'; +import { useStudioStore } from '../store/useStudioStore'; + +/** + * The pristine store state, captured at import time before any test has had a + * chance to mutate it. Zustand replaces the state object on every `set`, so + * this snapshot keeps its original values. + */ +const pristineState = useStudioStore.getState(); + +/** Restores the store (and the module-private API client) between tests. */ +export function resetStudioStore(): void { + // `disconnect` is what clears the store's private client reference and + // invalidates in-flight requests; `setState(…, true)` then replaces every + // remaining field with its initial value. + pristineState.disconnect(); + useStudioStore.setState(pristineState, true); +} + +export interface RouteResult { + status?: number; + body?: unknown; + /** Simulates an unreachable server (fetch itself rejecting). */ + networkError?: string; +} + +export type RouteHandler = RouteResult | ((init?: RequestInit) => RouteResult | Promise); + +/** + * Installs a `fetch` stub that answers by request path, e.g. + * `{ '/api/v1/health': { body: { status: 'healthy' } } }`. + * + * A handler may be a function returning a promise, which lets a test hold a + * response open and control the order in which concurrent requests resolve. + */ +export function mockServer(routes: Record): jest.Mock { + const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(input), 'http://localhost').pathname; + const handler = routes[path]; + if (!handler) { + throw new Error(`No stubbed route for ${path} — add it to mockServer({…})`); + } + + const result = typeof handler === 'function' ? await handler(init) : handler; + if (result.networkError) throw new TypeError(result.networkError); + + const status = result.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: async () => result.body, + } as Response; + }); + + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +/** A promise whose settlement the test controls, for ordering races. */ +export function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// --------------------------------------------------------------------------- +// Fixtures +// +// These mirror the payloads in src/api/__tests__/client.test.ts, which were +// taken from the GoLangGraph end-to-end suite driving a live server. They are +// snake_case because the Go structs tag every field — the studio's types once +// claimed Go's default PascalCase, which made every read undefined. +// --------------------------------------------------------------------------- + +export const agentFixture: AgentConfig = { + id: 'studio-agent', + name: 'Studio Agent', + type: 'chat', + model: 'fake-model', + provider: 'fake', + system_prompt: 'You are helpful.', + temperature: 0.7, + max_tokens: 1000, + max_iterations: 10, + tools: ['calculator'], + enable_streaming: true, + timeout: 30000000000, + metadata: {}, +}; + +export const reactAgentFixture: AgentConfig = { + ...agentFixture, + id: 'react-agent', + name: 'React Agent', + type: 'react', +}; + +export const executionFixture: AgentExecution = { + id: 'exec-1', + timestamp: '2024-01-01T00:00:00Z', + input: 'hello', + output: 'hi there', + structured_output: 'hi there', + tool_calls: null, + duration: 1500000, + success: true, + metadata: {}, + execution_path: ['chat'], +}; + +/** The health payload the server answers with; only its 200 status matters. */ +export const healthFixture = { status: 'healthy', timestamp: '2024-01-01T00:00:00Z', version: 'test' }; + +/** The `/graphs/{id}/topology` placeholder response: a well-formed empty graph. */ +export const emptyTopologyFixture = { + graph_id: 'studio-agent', + topology: { nodes: [], edges: [] }, +}; diff --git a/src/types/index.ts b/src/types/index.ts index 9bcc91c..20be2d9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,9 +30,10 @@ export interface AgentConfig { max_iterations: number; tools: string[]; enable_streaming: boolean; + /** Present only when the agent sets an explicit streaming mode. */ + streaming_mode?: string; timeout: number; // nanoseconds metadata?: Record; - interrupt_on?: string[]; } /** Mirrors `llm.ToolCall` JSON. */ @@ -54,31 +55,44 @@ export interface Usage { total_tokens: number; } -/** Mirrors `agent.AgentStep` JSON (Go default PascalCase fields). */ -export interface AgentStep { - NodeID: string; - Timestamp: string; - Input: Record; - Output: Record; - Error?: string; +/** + * Mirrors `agent.StateChange` JSON. + * + * The server records one entry per node visit while an agent runs, which is + * what the debug view steps through. + */ +export interface StateChange { + node_id: string; + node_name: string; + timestamp: string; + before?: Record; + after?: Record; } -/** Mirrors `agent.AgentExecution` JSON (Go default PascalCase fields). */ +/** + * Mirrors `agent.AgentExecution` JSON. + * + * The Go struct tags every field, so the wire format is snake_case like the + * rest of the API — not Go's default PascalCase. There is a single `timestamp` + * marking the start plus a `duration`, rather than separate start and end + * timestamps, and `execution_path` lists the nodes that ran. + */ export interface AgentExecution { - ID: string; - Input: string; - Output: unknown; - Success: boolean; - StartTime: string; - EndTime: string; - Duration: number; // nanoseconds - Status: string; - Steps: AgentStep[]; - ToolCalls: ToolCall[]; - Error?: string; - Metadata?: Record; - StructuredOutput?: unknown; - ExecutionPath: string[]; + id: string; + timestamp: string; + input: string; + /** Legacy flat string output, kept by the server for compatibility. */ + output: string; + structured_output?: unknown; + tool_calls: ToolCall[] | null; + /** Go `time.Duration`, serialised as integer nanoseconds. */ + duration: number; + success: boolean; + /** Present only when the execution failed. */ + error?: string; + metadata?: Record; + execution_path: string[]; + state_changes?: StateChange[]; } /** Mirrors `llm.ProviderConfig` (a subset of the useful fields). */