From 9c6f289874e09e1fb5ab92fe5f50dc04dda2f576 Mon Sep 17 00:00:00 2001 From: Piotr Laczkowski Date: Wed, 26 Aug 2026 21:39:56 +0000 Subject: [PATCH 1/3] fix: align execution types with the server's actual JSON contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentExecution and AgentStep types claimed the server used Go's default PascalCase field names. It does not: agent.AgentExecution tags every field, so the wire format is snake_case like the rest of the API. Every read of an execution — status, duration, output, tool calls and the execution path in both the debug view and the store — was therefore reading undefined, and the debug panel showed a blank result for runs that had actually succeeded. Verified against a live server: the GoLangGraph repository now carries an end-to-end suite that drives each of these endpoints and asserts the field names this file declares. Changes: - AgentExecution now mirrors the real payload: id, timestamp, input, output, structured_output, tool_calls, duration, success, error, metadata, execution_path, state_changes. There is one timestamp marking the start plus a duration, rather than the separate StartTime and EndTime that were declared but never sent. - AgentStep is replaced by StateChange, which is what the server actually records per node visit. No AgentStep type exists on the server. - DebugView and useStudioStore read the corrected field names, and the execution status shown is derived from the success flag, since the server sends no separate status field. Typecheck, lint and production build are clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JM31riXDsAT7MEtDs9crc --- src/api/client.ts | 2 +- src/components/views/DebugView.tsx | 14 ++++---- src/store/useStudioStore.ts | 16 ++++----- src/types/index.ts | 57 ++++++++++++++++++------------ 4 files changed, 51 insertions(+), 38 deletions(-) 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/views/DebugView.tsx b/src/components/views/DebugView.tsx index 1aada50..d66367e 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,19 @@ export const DebugView: React.FC = () => { {/* Status card */}
- {lastExecution.Success ? ( + {lastExecution.success ? ( ) : ( )} - {lastExecution.Status ?? (lastExecution.Success ? 'Completed' : 'Failed')} + {lastExecution.success ? 'Completed' : 'Failed'}
- } 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 +90,12 @@ export const DebugView: React.FC = () => {

Output

- +
-                  {extractOutputText(lastExecution.Output) || '—'}
+                  {extractOutputText(lastExecution.output) || '—'}
                 
diff --git a/src/store/useStudioStore.ts b/src/store/useStudioStore.ts index 80fa1a1..5ef7c02 100644 --- a/src/store/useStudioStore.ts +++ b/src/store/useStudioStore.ts @@ -391,17 +391,17 @@ export const useStudioStore = create()( set({ lastExecution: execution }); 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 ${execution.success ? 'completed' : 'failed'} in ${formatDuration(execution.duration)} (HTTP ${wallMs}ms)`, 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 ?? []; + const path = execution.execution_path ?? []; const nodes = get().graphNodes; nodes.forEach((node) => markNode(node.id, 'completed')); if (path.length > 0) { @@ -409,16 +409,16 @@ export const useStudioStore = create()( } 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.', timestamp: new Date(), metadata: { - duration_ns: execution.Duration, - status: execution.Status, - tool_calls: execution.ToolCalls?.length ?? 0, + duration_ns: execution.duration, + status: execution.success ? 'completed' : 'failed', + tool_calls: execution.tool_calls?.length ?? 0, }, }; get().addMessage(thread.id, assistantMessage); diff --git a/src/types/index.ts b/src/types/index.ts index 9bcc91c..da66134 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -54,31 +54,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). */ From 5e84f2d0c0f6eae2ecf32641e6805083cdd42b26 Mon Sep 17 00:00:00 2001 From: Piotr Laczkowski Date: Wed, 26 Aug 2026 22:08:15 +0000 Subject: [PATCH 2/3] test: add API contract tests; make CI enforce lint, types and tests The repository had no tests, and CI ran lint and tests with `|| true`, so neither could ever fail the build. Nothing would have caught the type mismatch this branch fixes: the declared execution shape compiled fine while disagreeing with what the server actually sends, and a compiler cannot know the wire format. Adds 17 tests for the API client that assert the real payload shapes. The fixtures are the payloads the Go server produces, taken from the end-to-end suite that drives a live server, so the two repositories now pin the same contract from both sides. They cover the API key header, error-field surfacing, unreachable servers and non-JSON error bodies; agent configurations and the empty-list fallbacks; successful and failed executions in snake_case; tools and provider descriptions; the graph topology the graph view renders, including conditional edges; and the output and tool-call display helpers. CI now typechecks with the project's TypeScript, and lint and tests fail the build rather than being swallowed. Also removes interrupt_on from AgentConfig, which the server has never sent, and adds streaming_mode, which it does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JM31riXDsAT7MEtDs9crc --- .github/workflows/build.yml | 4 +- .github/workflows/ci.yml | 9 +- src/api/__tests__/client.test.ts | 246 +++++++++++++++++++++++++++++++ src/types/index.ts | 3 +- 4 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/api/__tests__/client.test.ts 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/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/types/index.ts b/src/types/index.ts index da66134..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. */ From 4fd092df7383cfdc5e6aa0406c3e8948e3111540 Mon Sep 17 00:00:00 2001 From: Piotr Laczkowski Date: Thu, 27 Aug 2026 07:26:56 +0000 Subject: [PATCH 3/3] fix: surface failures to the user; test the store and views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console hid failures. A failed run reported only its duration, and because a failed execution usually has empty output the assistant bubble said "No output returned." — the server sends the reason in `error` and nothing read it. The debug panel showed the word "Failed", an empty output block and no explanation. Both now show the reason, and the store carries it into the execution log and the message metadata. A failed run also painted the whole graph green: every node was marked completed unconditionally before the execution path was applied. The NodeStatus 'error' existed in the types and nothing in the codebase ever produced it. Nodes on the server's execution_path are now completed and the rest idle; a failed run is marked error. Other defects fixed in the store: - connect() caught and discarded failures from listAgents, listTools and listProviders, so a server whose /agents endpoint returned 500 looked identical to one with no agents: "Connected", an empty list, no message. - Selecting one agent and then another let the first request's slower response overwrite the second's graph; topology fetches are now guarded by a request token. - Selecting an unknown agent left the previous agent's graph on screen, silently attributed to the new selection. - Stop did not stop anything: the request kept running and its answer was appended to the thread seconds later. A superseded result is now dropped. - The value computed for currentGraphNode was discarded immediately by the finally block, so the graph never highlighted where a run ended. - Reconnection failed silently once the retry budget was spent, leaving a Retry button that did nothing with no explanation. - disconnect() cleared some state and kept the rest, so the debug view described a run whose logs had just been wiped. Header indexed a three-key style map with a type string the server controls, emitting `class="… undefined"` for anything else. Adds 62 tests across the store and every view, driving the real store through the real API client with only fetch stubbed, asserting on rendered DOM and resulting state rather than on mock calls. Reverting the store fixes makes 11 of them fail, so they hold. Test suite: 20 -> 79. GraphView's Math.min over an empty array was investigated and is already guarded by an early return; a test pins that guard rather than changing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017JM31riXDsAT7MEtDs9crc --- package-lock.json | 201 +++++++ package.json | 43 +- src/components/layout/Header.tsx | 13 +- .../layout/__tests__/Header.test.tsx | 100 ++++ .../layout/__tests__/StudioLayout.test.tsx | 102 ++++ src/components/setup/ConnectionSetup.tsx | 5 + src/components/views/DebugView.tsx | 20 + .../views/__tests__/ChatView.test.tsx | 166 ++++++ .../views/__tests__/DebugView.test.tsx | 138 +++++ .../views/__tests__/GraphView.test.tsx | 131 +++++ src/setupTests.ts | 16 + src/store/__tests__/useStudioStore.test.ts | 490 ++++++++++++++++++ src/store/useStudioStore.ts | 190 ++++++- src/test-utils/studioTestUtils.ts | 136 +++++ 14 files changed, 1709 insertions(+), 42 deletions(-) create mode 100644 src/components/layout/__tests__/Header.test.tsx create mode 100644 src/components/layout/__tests__/StudioLayout.test.tsx create mode 100644 src/components/views/__tests__/ChatView.test.tsx create mode 100644 src/components/views/__tests__/DebugView.test.tsx create mode 100644 src/components/views/__tests__/GraphView.test.tsx create mode 100644 src/setupTests.ts create mode 100644 src/store/__tests__/useStudioStore.test.ts create mode 100644 src/test-utils/studioTestUtils.ts 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/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 d66367e..19e1da2 100644 --- a/src/components/views/DebugView.tsx +++ b/src/components/views/DebugView.tsx @@ -60,6 +60,26 @@ export const DebugView: React.FC = () => { {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="Tool calls" value={String(tools.length)} darkMode={darkMode} /> 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 5ef7c02..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,10 +484,28 @@ 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.success ? 'completed' : 'failed'} in ${formatDuration(execution.duration)} (HTTP ${wallMs}ms)`, + message: + `Execution ${outcome} in ${formatDuration(execution.duration)} (HTTP ${wallMs}ms)` + + (!execution.success && reason ? `: ${reason}` : ''), data: execution, }); @@ -400,30 +513,57 @@ export const useStudioStore = create()( get().addExecutionLog({ level: 'debug', message: `Tool call: ${call}` }); } - // Reflect node statuses from the execution path when available. + // 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 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.success ? 'completed' : 'failed', + 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: [] }, +};