From 4de018df136f68b3b50aff53c9bb5ef00591a80a Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 05:28:30 +0800 Subject: [PATCH] test(desktop): story-cover agent graph failure and edge states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh surface under #3944 (one surface per PR): the Agent Graph panel had no Storybook story. Add its failure and edge states, mounted through a scoped `window.maka.graphs` bridge inside the real composer host seam (.maka-detail-with-artifacts → .mainColumn), each pinning one AgentGraphClientSnapshot. - EmptyGraph — graph mode enabled, no operators yet: the panel's empty state. - FailedGraph — a failed operator beside completed/running ones. - BlockedOnUpstream — a blocked operator with the amber "waiting for input" line. - ManyOperators — a wide fan-out (28 operators) with no fabricated omitted count (the read-model only elides operators past 256) — review feedback. - LoadError — the IPC snapshot read rejects: the panel's error Banner + Retry. The panel renders operators as a flat list (no edges/hierarchy), so tree depth and cycles have no distinct rendering and are left out. Plays assert reachability of each state; exact row counts / omitted lines are read-model contracts left to the read-model's own tests (review feedback). Refs #3944, #3893 Generated-by: Claude Code --- .../stories/agent-graph-panel.stories.tsx | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 apps/desktop/stories/agent-graph-panel.stories.tsx diff --git a/apps/desktop/stories/agent-graph-panel.stories.tsx b/apps/desktop/stories/agent-graph-panel.stories.tsx new file mode 100644 index 0000000000..f43088dd1e --- /dev/null +++ b/apps/desktop/stories/agent-graph-panel.stories.tsx @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor } from 'storybook/test'; +import type { + AgentGraphClientOperator, + AgentGraphClientSnapshot, +} from '@maka/runtime/stream-graph-read-model'; +import { AgentGraphPanel, getAgentGraphPanelCopy } from '../src/renderer/agent-graph-panel'; +import { withScopedMakaBridge } from './maka-bridge'; + +// Fidelity convention (#1433): every story names the real app path that +// reaches it. See apps/desktop/stories/FIDELITY.md. +// +// Real host: app-shell.tsx mounts in the conversation column +// when the active session runs in `graph` orchestration mode. The panel reads +// its snapshot from `window.maka.graphs` itself (not props or context), so each +// story installs a scoped bridge that serves one pinned snapshot rather than +// driving a live graph. The panel renders operators as a flat list — it draws +// no edges or hierarchy — so tree depth and cycles have no distinct rendering +// and are not enumerated here. + +const ROOT_SESSION_ID = 'session-graph'; +const GRAPH_ID = 'graph-storybook'; +const LOCALE = 'zh'; +const copy = getAgentGraphPanelCopy(LOCALE); + +const meta = { + title: 'Product/Agent Graph', + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function snapshot( + overrides: Pick & Partial, +): AgentGraphClientSnapshot { + return { + schemaVersion: 1, + rootSessionId: ROOT_SESSION_ID, + graphId: GRAPH_ID, + orchestrationMode: 'graph', + snapshotVersion: '1', + scheduleRevision: 1, + topologyFingerprint: 'fp', + closed: overrides.status === 'completed', + operators: [], + edges: [], + work: [], + reconciliationFailures: [], + stoppedTargets: [], + claims: [], + recentControlDecisions: [], + recentActivity: [], + terminalHistory: { records: [] }, + omitted: { + operators: 0, + edges: 0, + work: 0, + reconciliationFailures: 0, + stoppedTargets: 0, + claims: 0, + controlDecisions: 0, + recentActivity: 0, + }, + ...overrides, + }; +} + +function operator( + overrides: Pick & + Partial, +): AgentGraphClientOperator { + return { + childSessionId: `child-${overrides.operatorId}`, + provisionId: `prov-${overrides.operatorId}`, + agentId: 'code-reviewer', + provisionedAt: 1, + inboundEdgeIds: [], + outboundEdgeIds: [], + scheduledWorkIds: [], + readiness: [], + omitted: { + inboundEdgeIds: 0, + outboundEdgeIds: 0, + scheduledWorkIds: 0, + readiness: 0, + readinessWaits: 0, + }, + ...overrides, + }; +} + +// A scoped `window.maka.graphs` bridge that serves one pinned snapshot. `fail` +// makes getSnapshot reject, exercising the panel's load-error banner. The full +// method set is required: the panel calls `subscribe` outside its try/catch, so +// a missing bridge would throw into React rather than settle into the UI. +function graphBridge(snap: AgentGraphClientSnapshot, fail = false) { + const directory = { + epochs: [{ epoch: 1, graphId: snap.graphId, createdAt: 1, current: true }], + truncated: false, + }; + return { + graphs: { + listEpochs: async () => directory, + listCurrentEpochs: async () => directory, + getSnapshot: async () => { + if (fail) throw new Error('graph read failed'); + return snap; + }, + inspectOperator: async () => { + throw new Error('inspectOperator is unused by AgentGraphPanel'); + }, + subscribe: (_sessionId: string, _listener: () => void) => () => undefined, + stop: async () => undefined, + }, + }; +} + +// Production mounts in the conversation composer slot +// (app-shell.tsx: ChatSurfaceLayout composer → `.mainColumn` → +// `.maka-detail-with-artifacts`). Reuse those wrapper classes so the panel +// inherits the composer column's seam rather than an arbitrary fixed box. The +// full AppShell grid and Composer chrome around it are not rebuilt here — that +// seam lives in Product/Shell Official AppShell, and its geometry is pinned by +// e2e/session-workbar.spec.ts — so this isolates the panel itself at a +// composer-column width. +function panel() { + return ( +
+
+ undefined} + /> +
+
+ ); +} + +// Real path: graph mode is enabled but the main agent has not provisioned any +// operator yet — the panel's own empty state, not a spinner and not an error. +export const EmptyGraph: Story = { + decorators: [ + withScopedMakaBridge(graphBridge(snapshot({ status: 'empty', scheduleRevision: 0 }))), + ], + render: panel, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.textContent).toContain(copy.noOperators)); + }, +}; + +// Real path: an operator failed and the graph settled failed. The failed row +// wears its status dot and label beside the operators that finished or are +// still running. +export const FailedGraph: Story = { + decorators: [ + withScopedMakaBridge( + graphBridge( + snapshot({ + status: 'failed', + operators: [ + operator({ operatorId: 'op-plan', status: 'completed' }), + operator({ operatorId: 'op-build', status: 'failed' }), + operator({ operatorId: 'op-verify', status: 'running' }), + ], + }), + ), + ), + ], + render: panel, + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelector('.maka-agent-graph-operators li[data-status="failed"]'), + ).not.toBeNull(), + ); + }, +}; + +// Real path: an operator cannot start until an upstream operator routes its +// input, so it sits blocked with the amber "waiting for …" line. +export const BlockedOnUpstream: Story = { + decorators: [ + withScopedMakaBridge( + graphBridge( + snapshot({ + status: 'active', + operators: [ + operator({ operatorId: 'op-planner', status: 'running' }), + operator({ + operatorId: 'op-writer', + status: 'blocked', + readiness: [ + { + readinessId: 'r-writer', + status: 'waiting', + waitingFor: [{ kind: 'input_route', upstreamOperatorIds: ['op-planner'] }], + omittedWaitingFor: 0, + }, + ], + }), + ], + }), + ), + ), + ], + render: panel, + play: async ({ canvasElement }) => { + await waitFor(() => { + expect( + canvasElement.querySelector('.maka-agent-graph-operators li[data-status="blocked"]'), + ).not.toBeNull(); + expect(canvasElement.querySelector('.maka-agent-graph-wait')).not.toBeNull(); + }); + }, +}; + +// Real path: a wide fan-out — many operators provisioned at once, the breadth a +// small graph never shows. The read-model only elides operators past 256 (far +// beyond a story's scale), so this shows a genuine many-operator list rather +// than a fabricated omitted count (review feedback). +export const ManyOperators: Story = { + decorators: [ + withScopedMakaBridge( + graphBridge( + snapshot({ + status: 'active', + operators: Array.from({ length: 28 }, (_, index) => + operator({ + operatorId: `op-${index}`, + status: index % 3 === 0 ? 'running' : 'completed', + }), + ), + }), + ), + ), + ], + render: panel, + play: async ({ canvasElement }) => { + // The full list renders (its last operator is reachable). An exact row + // count and the omitted "+N" line are read-model contracts, covered by the + // read-model's own tests rather than asserted here (review feedback). + await waitFor(() => expect(canvasElement.textContent).toContain('op-27')); + }, +}; + +// Real path: reading the graph snapshot fails (the IPC read rejects); the panel +// raises its error Banner with a Retry action instead of a blank pane. +export const LoadError: Story = { + decorators: [withScopedMakaBridge(graphBridge(snapshot({ status: 'active' }), true))], + render: panel, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.textContent).toContain(copy.loadFailed)); + }, +};